API DeepSeek V4: o guia completo do desenvolvedor


DeepSeek V3 marca uma mudança notável no mercado LLM de peso aberto, fornecendo raciocínio competitivo e resultados de geração de código por uma fração do custo por token do GPT-4o (veja o Página de preços do DeepSeek para taxas atuais). Para desenvolvedores que criam aplicativos JavaScript, a API DeepSeek oferece um caminho direto para raciocínio de alta qualidade, geração de código e recursos de conversação multivoltas sem o preço associado ao GPT-4o ou Claude 3.5 Sonnet.

Este guia aborda tudo o que é necessário para passar do zero a um aplicativo funcional: configuração do ambiente, principais recursos da API, streaming, saída estruturada, uma ferramenta completa de revisão de código e migração de outros provedores.

Como integrar a API DeepSeek V3 em um projeto JavaScript

  1. Criar uma conta DeepSeek em platform.deepseek.com e gere uma chave API.
  2. Loja a chave em um .env arquivo e adicione-o ao .gitignore.
  3. Instalar o OpenAI SDK e dotenv: npm install openai@4 dotenv@16.
  4. Configurar o SDK com o URL base do DeepSeek e sua chave de API.
  5. Enviar uma solicitação de conclusão de bate-papo usando o deepseek-chat modelo.
  6. Habilitar streaming para menor tempo até o primeiro token em aplicativos voltados para o usuário.
  7. Implementar lógica de nova tentativa com espera exponencial para erros 429 e 5xx.
  8. Monitor uso de token por meio da resposta usage objeto para controle de custos.

Índice

Ao final, os leitores terão construído uma ferramenta CLI funcional de revisão de código alimentada por IA e compreenderão a principal área de superfície da API DeepSeek por meio do SDK compatível com OpenAI.

Você precisará do Node.js 18 ou posterior, npm, uma chave de API DeepSeek (abordada abaixo) e familiaridade com JavaScript e padrões assíncronos/aguardados.

O que há de novo no DeepSeek V3

Melhorias de arquitetura e desempenho

DeepSeek V3 usa uma arquitetura Mixture-of-Experts (MoE) com roteamento especializado refinado e uma janela de contexto de token de 64K. O modelo apresenta pontuações competitivas em benchmarks de geração de código, como HumanEval e MBPP, e tem um bom desempenho em tarefas de raciocínio matemático (GSM8K, MATH) e acompanhamento de instruções. Contra concorrentes de ponta, o DeepSeek V3 se posiciona no mesmo nível que o GPT-4o e o Gemini 2.5 Pro nesses benchmarks, mantendo a disponibilidade de peso aberto que distingue a família DeepSeek.

Mudanças e compatibilidade de API

A superfície da API permanece compatível com OpenAI, o que significa que qualquer aplicativo criado com base na especificação da API REST OpenAI pode mudar para DeepSeek alterando o URL base e o identificador do modelo. O identificador principal do modelo de API é deepseek-chatque atualmente é resolvido para o modelo DeepSeek V3. A estrutura de preços da DeepSeek continua a prejudicar significativamente os principais concorrentes nos custos de entrada e saída de tokens, tornando-a particularmente atraente para aplicações de alto volume. Novos parâmetros e modos de saída estruturados estão disponíveis, detalhados nas seções a seguir.

Qualquer aplicativo desenvolvido com base na especificação da API REST OpenAI pode mudar para DeepSeek alterando o URL base e o identificador do modelo.

Primeiros passos: chave de API e configuração de ambiente

Criando sua conta DeepSeek e chave de API

As inscrições começam às plataforma.deepseek.com. Depois de criar uma conta, navegue até a seção Chaves de API no painel e gere uma nova chave. Copie a chave imediatamente; não será mostrado novamente.

Armazene a chave em uma variável de ambiente. Nunca codifique chaves de API em arquivos de origem, submeta-as ao controle de versão ou exponha-as no código do lado do cliente.

Inicialização do Projeto

Configure um novo projeto Node.js e instale as dependências necessárias:

mkdir deepseek-v3-demo
cd deepseek-v3-demo
npm init -y
npm install openai@4 dotenv@16

Este guia foi testado com openai@4.xe dotenv@16.x. Fixe versões para evitar alterações significativas.

Todos os arquivos de exemplo usam o .mjs extensão para ativar a sintaxe do módulo ES (incluindo nível superior await). Alternativamente, adicione "type": "module" para package.json usar import em .js arquivos.

Crie um .env arquivo na raiz do projeto:

DEEPSEEK_API_KEY=your_api_key_here
DEEPSEEK_BASE_URL=https://api.deepseek.com

Adicionar .env para .gitignore para evitar exposição acidental:

echo ".env" >> .gitignore

Sua primeira chamada de API DeepSeek V3

Configurando o OpenAI SDK para DeepSeek

O OpenAI Node.js SDK aceita um baseURL parâmetro do construtor. Apontando para https://api.deepseek.com roteia todas as solicitações para os servidores do DeepSeek, preservando exatamente as mesmas assinaturas de método, formatos de solicitação e formas de resposta. Você não precisa de uma biblioteca ou adaptador wrapper.

Crie um arquivo chamado basic.mjs:

import "dotenv/config";
import OpenAI from "openai";

const apiKey = process.env.DEEPSEEK_API_KEY;
const baseURL = process.env.DEEPSEEK_BASE_URL;

if (!apiKey || apiKey.trim() === "") {
  console.error("Error: DEEPSEEK_API_KEY is not set or is empty in your .env file.");
  process.exit(1);
}
if (!baseURL || baseURL.trim() === "") {
  console.error("Error: DEEPSEEK_BASE_URL is not set or is empty in your .env file.");
  process.exit(1);
}

const client = new OpenAI({
  baseURL,
  apiKey,
  timeout: 60_000,
  maxRetries: 0,
});

const response = await client.chat.completions.create({
  model: "deepseek-chat",
  messages: (
    { role: "system", content: "You are a helpful programming assistant." },
    { role: "user", content: "Explain the difference between map and flatMap in JavaScript." },
  ),
});

const choice = response.choices?.(0);
if (!choice || choice.finish_reason === "content_filter") {
  console.error(
    "No valid completion returned. finish_reason:",
    choice?.finish_reason ?? "no choices"
  );
  process.exit(1);
}
const content = choice.message?.content;
if (typeof content !== "string") {
  console.error("Unexpected response shape: missing message content.");
  process.exit(1);
}

console.log(content);
console.log("Token usage:", response.usage);

Corra com node basic.mjs.

Compreendendo o objeto de resposta

A resposta segue o esquema de conclusão do chat OpenAI. response.choices é uma matriz onde cada entrada contém um message objeto com role e content campos. O finish_reason campo indica porque a geração parou: "stop" para conclusão natural, "length" se a resposta atingiu o max_tokens boné, "tool_calls" se o modelo invocou uma função, ou "content_filter" se a filtragem de conteúdo bloqueou a resposta. O usage relatórios de objetos prompt_tokens, completion_tokense total_tokensque mapeiam diretamente para o faturamento. O monitoramento desses valores é essencial para o acompanhamento dos custos na produção.

Principais recursos e parâmetros da API

Prompts do sistema e conversas múltiplas

O messages array suporta três funções: system (define comportamento e restrições), user (entrada do usuário final) e assistant (respostas modelo de turnos anteriores). As conversas multivoltas exigem que o desenvolvedor mantenha e anexe a essa matriz entre as interações.

Criar multiturn.mjs:

import "dotenv/config";
import OpenAI from "openai";

const apiKey = process.env.DEEPSEEK_API_KEY;
const baseURL = process.env.DEEPSEEK_BASE_URL;

if (!apiKey || apiKey.trim() === "") {
  console.error("Error: DEEPSEEK_API_KEY is not set or is empty in your .env file.");
  process.exit(1);
}
if (!baseURL || baseURL.trim() === "") {
  console.error("Error: DEEPSEEK_BASE_URL is not set or is empty in your .env file.");
  process.exit(1);
}

const client = new OpenAI({
  baseURL,
  apiKey,
  timeout: 60_000,
  maxRetries: 0,
});

const conversationHistory = (
  { role: "system", content: "You are a senior JavaScript developer. Be concise and precise." },
);

const MAX_HISTORY_TURNS = 10; 

function appendAndTrim(history, role, content) {
  history.push({ role, content });
  
  const systemPrompt = history(0).role === "system" ? (history(0)) : ();
  const turns = history.slice(systemPrompt.length);
  const maxTurnMessages = MAX_HISTORY_TURNS * 2; 
  const trimmed = turns.slice(Math.max(0, turns.length - maxTurnMessages));
  history.length = 0;
  history.push(...systemPrompt, ...trimmed);
}

async function chat(userMessage) {
  appendAndTrim(conversationHistory, "user", userMessage);

  const response = await client.chat.completions.create({
    model: "deepseek-chat",
    messages: conversationHistory,
  });

  const choice = response.choices?.(0);
  if (!choice || choice.finish_reason === "content_filter") {
    throw new Error(
      `No valid completion returned. finish_reason: ${choice?.finish_reason ?? "no choices"}`
    );
  }
  const assistantMessage = choice.message?.content;
  if (typeof assistantMessage !== "string") {
    throw new Error("Unexpected response shape: missing message content.");
  }
  appendAndTrim(conversationHistory, "assistant", assistantMessage);

  return assistantMessage;
}

console.log(await chat("What is a closure in JavaScript?"));
console.log(await chat("Can you give me a practical example of one?"));
console.log(await chat("How does that relate to the module pattern?"));

Cada chamada envia o histórico acumulado (cortado em uma janela deslizante), permitindo que o modelo faça referência a curvas anteriores sem crescimento ilimitado de memória.

Parâmetros-chave para controlar a saída

A API aceita vários parâmetros para moldar o comportamento de geração. temperature (0 a 2) controla a aleatoriedade; valores mais baixos produzem resultados mais determinísticos. Verifique o Documentos da API DeepSeek para o padrão atual. Usar top_p (0 a 1) para amostragem de núcleo, e max_tokens para limitar o comprimento da resposta. frequency_penalty e presence_penalty (ambos -2 a 2 de acordo com as especificações compatíveis com OpenAI; verifique se esses parâmetros são respeitados pelo endpoint DeepSeek, pois o comportamento pode ser diferente do OpenAI) desencoraja a repetição e incentiva a diversidade de tópicos, respectivamente. Se você precisar interromper a geração em strings delimitadoras específicas, passe-as por meio do stop parâmetro como uma matriz.

Para saída estruturada, defina response_format: { type: "json_object" } e instrua o modelo no sistema ou prompt do usuário para produzir JSON. Onde o endpoint oferece suporte, esse modo aumenta a probabilidade de saída JSON válida. Verifique o suporte no Documentos da API DeepSeek e sempre embrulhe JSON.parse() em um bloco try/catch.

Criar jsonmode.mjs:

import "dotenv/config";
import OpenAI from "openai";

const apiKey = process.env.DEEPSEEK_API_KEY;
const baseURL = process.env.DEEPSEEK_BASE_URL;

if (!apiKey || apiKey.trim() === "") {
  console.error("Error: DEEPSEEK_API_KEY is not set or is empty in your .env file.");
  process.exit(1);
}
if (!baseURL || baseURL.trim() === "") {
  console.error("Error: DEEPSEEK_BASE_URL is not set or is empty in your .env file.");
  process.exit(1);
}

const client = new OpenAI({
  baseURL,
  apiKey,
  timeout: 60_000,
  maxRetries: 0,
});

const response = await client.chat.completions.create({
  model: "deepseek-chat",
  response_format: { type: "json_object" },
  messages: (
    {
      role: "system",
      content: "You are an API that returns JSON. Always respond with a valid JSON object.",
    },
    {
      role: "user",
      content: "List three common JavaScript array methods with their descriptions and return types.",
    },
  ),
});

const choice = response.choices?.(0);
if (!choice || choice.finish_reason === "content_filter") {
  console.error(
    "No valid completion returned. finish_reason:",
    choice?.finish_reason ?? "no choices"
  );
  process.exit(1);
}
const rawContent = choice.message?.content;
if (typeof rawContent !== "string") {
  console.error("Unexpected response shape: missing message content.");
  process.exit(1);
}

let parsed;
try {
  parsed = JSON.parse(rawContent);
} catch (e) {
  console.error("Failed to parse model response as JSON:", e.message);
  console.error("Raw response:", rawContent);
  process.exit(1);
}

const isValidObject = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed);
console.log(isValidObject ? "Valid JSON object received" : "Unexpected format");
console.log(JSON.stringify(parsed, null, 2));

Respostas de streaming

O streaming reduz a latência percebida ao entregar tokens à medida que são gerados, o que é fundamental para aplicativos voltados para o usuário, onde o tempo até o primeiro token (TTFT – o tempo decorrido entre o envio de uma solicitação e o recebimento do primeiro token da resposta) é mais importante do que o tempo total de geração.

Criar streaming.mjs:

import "dotenv/config";
import OpenAI from "openai";

const apiKey = process.env.DEEPSEEK_API_KEY;
const baseURL = process.env.DEEPSEEK_BASE_URL;

if (!apiKey || apiKey.trim() === "") {
  console.error("Error: DEEPSEEK_API_KEY is not set or is empty in your .env file.");
  process.exit(1);
}
if (!baseURL || baseURL.trim() === "") {
  console.error("Error: DEEPSEEK_BASE_URL is not set or is empty in your .env file.");
  process.exit(1);
}

const client = new OpenAI({
  baseURL,
  apiKey,
  timeout: 60_000,
  maxRetries: 0,
});

const stream = await client.chat.completions.create({
  model: "deepseek-chat",
  messages: (
    { role: "user", content: "Write a brief explanation of event-driven architecture." },
  ),
  stream: true,
});

let fullResponse = "";

for await (const chunk of stream) {
  const content = chunk.choices?.(0)?.delta?.content ?? "";
  process.stdout.write(content);
  fullResponse += content;
}

console.log("

Full response length:", fullResponse.length, "characters");

Cada pedaço contém um delta objeto com incremental content. O loop monta a resposta completa enquanto grava simultaneamente no stdout.

Construindo um aplicativo completo: Revisor de código com tecnologia de IA

Arquitetura de Aplicativo

Esta ferramenta CLI lê um arquivo JavaScript do disco, envia seu conteúdo para DeepSeek com um prompt detalhado do sistema de revisão de código e solicita feedback JSON estruturado. O aplicativo exercita a compreensão do código, o raciocínio e os recursos de saída estruturados do DeepSeek V3 em um único fluxo de trabalho coeso.

Esta ferramenta CLI lê um arquivo JavaScript do disco, envia seu conteúdo para DeepSeek com um prompt detalhado do sistema de revisão de código e solicita feedback JSON estruturado.

Criar review.mjs:

import "dotenv/config";
import OpenAI from "openai";
import { readFile } from "fs/promises";
import { resolve, extname } from "path";

const apiKey = process.env.DEEPSEEK_API_KEY;
const baseURL = process.env.DEEPSEEK_BASE_URL;

if (!apiKey || apiKey.trim() === "") {
  console.error("Error: DEEPSEEK_API_KEY is not set or is empty in your .env file.");
  process.exit(1);
}
if (!baseURL || baseURL.trim() === "") {
  console.error("Error: DEEPSEEK_BASE_URL is not set or is empty in your .env file.");
  process.exit(1);
}

const client = new OpenAI({
  baseURL,
  apiKey,
  timeout: 60_000,
  maxRetries: 0,
});

const filePath = process.argv(2);
if (!filePath) {
  console.error("Usage: node review.mjs ");
  process.exit(1);
}

const resolvedPath = resolve(filePath);
const allowedBase = resolve(process.cwd());
if (!resolvedPath.startsWith(allowedBase + "https://www.sitepoint.com/") && resolvedPath !== allowedBase) {
  console.error("Error: File must be within the current working directory.");
  process.exit(1);
}

const allowedExtensions = (".js", ".mjs", ".cjs", ".ts");
if (!allowedExtensions.includes(extname(resolvedPath))) {
  console.error("Error: Only JavaScript/TypeScript files are supported.");
  process.exit(1);
}


const code = await readFile(resolvedPath, "utf-8");
if (Buffer.byteLength(code, "utf-8") > 100_000) {
  console.error("File too large (>100KB). Truncate or split before reviewing.");
  process.exit(1);
}

const systemPrompt = `You are a senior code reviewer. Analyze the provided JavaScript code and return a JSON object with the following structure:
{
  "summary": "Brief overall assessment",
  "issues": (
    {
      "severity": "high" | "medium" | "low",
      "line": ,
      "description": "What the issue is",
      "suggestion": "How to fix it"
    }
  ),
  "strengths": ("List of things done well"),
  "score": 
}
Only return valid JSON. No markdown fences.`;

const response = await client.chat.completions.create({
  model: "deepseek-chat",
  response_format: { type: "json_object" },
  temperature: 0.3,
  max_tokens: 2048,
  messages: (
    { role: "system", content: systemPrompt },
    { role: "user", content: `Review this code:

${code}` },
  ),
});

const choice = response.choices?.(0);
if (!choice || choice.finish_reason === "content_filter") {
  console.error(
    "No valid completion returned. finish_reason:",
    choice?.finish_reason ?? "no choices"
  );
  process.exit(1);
}
const responseContent = choice.message?.content;
if (typeof responseContent !== "string") {
  console.error("Unexpected response shape: missing message content.");
  process.exit(1);
}

let review;
try {
  review = JSON.parse(responseContent);
} catch (e) {
  console.error("Failed to parse model response as JSON:", e.message);
  console.error("Raw response:", responseContent);
  process.exit(1);
}

console.log(`
📋 Code Review: ${filePath}`);
console.log(`Score: ${review.score}/10`);
console.log(`Summary: ${review.summary}
`);

if (review.issues?.length) {
  console.log("Issues:");
  review.issues.forEach((issue, i) => {
    const severityRaw = issue.severity;
    const severity =
      typeof severityRaw === "string" ? severityRaw.toUpperCase() : "UNKNOWN";
    const line = issue.line != null ? ` (line ~${issue.line})` : "";
    const description =
      typeof issue.description === "string" ? issue.description : "(no description)";
    const suggestion =
      typeof issue.suggestion === "string" ? issue.suggestion : "(no suggestion)";
    console.log(`  ${i + 1}. (${severity})${line} ${description}`);
    console.log(`     Fix: ${suggestion}`);
  });
}

if (review.strengths?.length) {
  console.log("
Strengths:");
  review.strengths.forEach((s) => console.log(`${s}`));
}

console.log(`
Tokens used: ${response.usage?.total_tokens ?? "unknown"}`);

Execute em qualquer arquivo JavaScript: node review.mjs ./basic.mjs.

Aprimorando com tratamento de erros e novas tentativas

As chamadas de API de produção devem levar em conta limites de taxa (HTTP 429) e erros transitórios do servidor (5xx). DeepSeek retorna cabeçalhos de limite de taxa padrão. Um wrapper de nova tentativa com espera exponencial lida com ambos os casos normalmente.

Criar retry.mjs:

export async function withRetry(fn, { maxRetries = 3, baseDelay = 1000 } = {}) {
  if (maxRetries  1) throw new RangeError("maxRetries must be >= 1");

  let lastError;
  for (let attempt = 0; attempt  maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error;
      const status = error?.status ?? error?.response?.status;

      
      
      const isRetryable =
        typeof status === "number" &&
        (status === 429 || (status >= 500 && status  600));

      if (!isRetryable || attempt === maxRetries - 1) {
        throw error;
      }

      const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 500;
      console.warn(
        `Retryable error (HTTP ${status}): ${error.message}. ` +
          `Attempt ${attempt + 1}/${maxRetries}. Waiting ${Math.round(delay)}ms...`
      );
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
  
  throw lastError;
}

Exemplo de uso em outro arquivo:

import { withRetry } from "./retry.mjs";

const response = await withRetry(() => client.chat.completions.create({  }));

A função aplica espera exponencial com jitter, tenta novamente apenas em códigos de status HTTP 429 e 5xx e lança imediatamente em erros que não podem ser repetidos (incluindo erros não HTTP, como falhas de rede ou erros de resolução de DNS).

Guia de migração: migrando de OpenAI ou outros provedores

A migração em duas linhas

Para aplicativos que já usam o SDK OpenAI Node.js, mudar para DeepSeek requer a alteração de no mínimo dois valores: o URL base e o identificador do modelo. Revise a seção de diferenças comportamentais abaixo antes de assumir uma troca imediata.


const client = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});



const client = new OpenAI({
  baseURL: "https://api.deepseek.com",
  apiKey: process.env.DEEPSEEK_API_KEY,
});

O restante do código do aplicativo, incluindo formatação de mensagens, passagem de parâmetros e análise de respostas, permanece idêntico.

Diferenças comportamentais a serem observadas

Apesar da compatibilidade da API, os modelos diferem de maneira sutil. O comportamento padrão da temperatura, os padrões de limite de token e a sensibilidade do prompt do sistema variam entre os provedores. DeepSeek V3 pode produzir resultados visivelmente diferentes para o mesmo prompt; por exemplo, tende a gerar respostas mais curtas e diretas a perguntas abertas do que o GPT-4o e pode interpretar instruções ambíguas de forma mais literal. DeepSeek V3 suporta chamada de função (uso de ferramenta) usando o mesmo esquema de ferramenta OpenAI; no entanto, o DeepSeek não confirmou a paridade para todos os recursos OpenAI (por exemplo, ajuste fino, certos modos de formato de resposta), portanto, verifique os recursos suportados mais recentes no Documentação da API DeepSeek.

A estratégia de teste recomendada: executar conjuntos de prompts existentes por meio de ambos os provedores, comparar a qualidade e a estrutura da saída e ajustar os prompts do sistema onde o comportamento do DeepSeek V3 diverge.

Melhores práticas e otimização

Dicas imediatas de engenharia para DeepSeek V3

DeepSeek V3 responde bem a instruções estruturadas com especificações explícitas de formato de saída. A solicitação de cadeia de pensamento (pedir ao modelo para raciocinar passo a passo antes de responder) melhora a precisão em tarefas matemáticas e de raciocínio em várias etapas nas avaliações publicadas do DeepSeek. Prompts vagos e janelas de contexto muito longas sem foco claro tendem a degradar a qualidade da saída; compare seu caso de uso específico, mas trate a degradação da qualidade além de aproximadamente 40 mil tokens de contexto como uma suposição padrão razoável para validação.

Reduzindo custos de token

Estime a contagem de tokens antes de enviar solicitações usando bibliotecas de tokenizadores para evitar custos inesperados. Observe que o OpenAI tiktoken a biblioteca usa um tokenizer diferente do DeepSeek V3, portanto, a contagem de tokens será aproximada; verifique a documentação do DeepSeek para obter um tokenizer compatível se forem necessárias estimativas precisas. Respostas em cache para consultas repetidas ou idênticas. Definir max_tokens ao mínimo necessário para cada caso de uso, em vez de depender de padrões. Para tarefas mais simples, como classificação ou extração de formatos curtos, considere se um modelo mais leve e mais barato é suficiente antes de encaminhar tudo por meio do DeepSeek V3.

Estime a contagem de tokens antes de enviar solicitações usando bibliotecas de tokenizadores para evitar custos inesperados.

Lista de verificação de implementação

Configurar

  • Conta DeepSeek criada e chave de API gerada
  • Chave de API armazenada na variável de ambiente (nunca codificada)
  • OpenAI SDK instalado e configurado com URL base DeepSeek
  • Conclusão básica do chat funcionando

Teste

  • Tratamento de erros e lógica de nova tentativa implementada
  • Streaming implementado para recursos voltados ao usuário
  • Modo JSON testado para saídas estruturadas
  • Tratamento de limite de taxa confirmado
  • Prompts existentes testados e adaptados para diferenças comportamentais do DeepSeek V3

Produção

  • Monitoramento de uso de token em vigor
  • Valide estimativas de custos em relação aos níveis de preços
  • Registro e monitoramento de produção configurados

O que vem a seguir

Comece com os exemplos de código acima e explore o documentação oficial da API DeepSeek para obter as novidades sobre suporte para chamadas de funções, recursos de ajuste fino e especificações de limite de taxa. O DeepSeek V3 combina baixo custo por token com resultados de benchmark competitivos por meio de uma superfície de API familiar, de modo que o custo de migração é baixo para equipes que já usam o OpenAI SDK.

A natureza aberta dos modelos DeepSeek permite a auto-hospedagem, sujeita aos termos do Contrato de licença DeepSeekque inclui restrições ao uso comercial. Revise a licença antes de auto-hospedar para fins comerciais ou de produção.



Source link