Один ключ к DeepSeek, GPT, Claude и Gemini — оплата в рублях, доступ без VPN.

Получить ключ

DeepSeek API на Go

API DeepSeek совместим с форматом OpenAI, поэтому на Go для работы достаточно стандартной библиотеки: net/http, encoding/json, bufio. Никаких внешних зависимостей — только go.mod и код ниже.

Почему Go подходит для таких задач

Go выигрывает там, где PHP и Python начинают упираться в конкурентность и время удержания соединения:

  • сервисы и микросервисы, которые принимают запрос от пользователя и сами обращаются к модели;
  • прокси и шлюзы — единая точка входа к API с логированием, лимитами и подстановкой ключа;
  • боты и фоновые обработчики, которые держат десятки одновременных потоковых ответов;
  • утилиты командной строки и воркеры очередей, собираемые в один бинарник без интерпретатора.

Практически важны три вещи: context даёт отмену и таймаут на каждый запрос, горутины позволяют обрабатывать несколько стримов параллельно, а SSE в потоковом режиме читается построчно через bufio.Scanner без промежуточных буферов.

Что понадобится

Go 1.22 или новее, ключ DEEPSEEK_API_KEY, адрес https://api.deepseek.com и метод POST /chat/completions. Авторизация — заголовок Authorization: Bearer ${DEEPSEEK_API_KEY}. Актуальные модели для примеров: deepseek-flash и deepseek-v4-pro. Помните, что режим рассуждений включён по умолчанию, а temperature в нём не действует.

Типы запроса и ответа

Начнём с типов. Достаточно описать только те поля, которые действительно нужны: остальные encoding/json просто проигнорирует.

go
package main

// Message — одно сообщение диалога. Роли: system, user, assistant, tool.
type Message struct {
	Role    string `json:"role"`
	Content string `json:"content"`
}

// StreamOptions включает чанк с расходом токенов.
// Работает только при Stream: true — иначе API вернёт 400.
type StreamOptions struct {
	IncludeUsage bool `json:"include_usage"`
}

// Request — тело запроса к POST /chat/completions.
type Request struct {
	Model         string         `json:"model"`
	Messages      []Message      `json:"messages"`
	Stream        bool           `json:"stream,omitempty"`
	StreamOptions *StreamOptions `json:"stream_options,omitempty"`
}

type Choice struct {
	Index        int     `json:"index"`
	Message      Message `json:"message"`
	FinishReason string  `json:"finish_reason"`
}

type Usage struct {
	PromptTokens          int `json:"prompt_tokens"`
	CompletionTokens      int `json:"completion_tokens"`
	TotalTokens           int `json:"total_tokens"`
	PromptCacheHitTokens  int `json:"prompt_cache_hit_tokens"`
	PromptCacheMissTokens int `json:"prompt_cache_miss_tokens"`
}

type Response struct {
	ID      string   `json:"id"`
	Model   string   `json:"model"`
	Choices []Choice `json:"choices"`
	Usage   *Usage   `json:"usage"`
}

// Chunk — один фрагмент потокового ответа (SSE).
type Chunk struct {
	Choices []struct {
		Delta struct {
			Content string `json:"content"`
		} `json:"delta"`
		FinishReason string `json:"finish_reason"`
	} `json:"choices"`
	Usage *Usage `json:"usage"`
}

В нестриминговом ответе текст лежит в choices[0].message.content, в потоковом — в choices[0].delta.content. Цепочка рассуждений приходит в поле reasoning_content на одном уровне с content — добавьте его в Message, если планируете читать рассуждения или работать с tools.

Минимальный запрос

Базовый вариант: контекст с таймаутом, сериализация тела, проверка статуса и разбор ответа декодером.

go
package main

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"time"
)

const (
	baseURL = "https://api.deepseek.com"
	model   = "deepseek-flash"
)

// ask отправляет один запрос и возвращает ответ модели.
func ask(ctx context.Context, prompt string) (*Response, error) {
	body, err := json.Marshal(Request{
		Model: model,
		Messages: []Message{
			{Role: "system", Content: "Отвечай по-русски, кратко и по делу."},
			{Role: "user", Content: prompt},
		},
	})
	if err != nil {
		return nil, fmt.Errorf("marshal request: %w", err)
	}

	ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/chat/completions", bytes.NewReader(body))
	if err != nil {
		return nil, fmt.Errorf("new request: %w", err)
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+os.Getenv("DEEPSEEK_API_KEY"))

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, fmt.Errorf("do request: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		detail, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
		return nil, fmt.Errorf("deepseek: status %d: %s", resp.StatusCode, bytes.TrimSpace(detail))
	}

	var out Response
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return nil, fmt.Errorf("decode response: %w", err)
	}
	if len(out.Choices) == 0 {
		return nil, fmt.Errorf("deepseek: в ответе нет choices")
	}

	return &out, nil
}

func main() {
	resp, err := ask(context.Background(), "Что такое контекстное кэширование в API?")
	if err != nil {
		fmt.Fprintln(os.Stderr, "ошибка:", err)
		os.Exit(1)
	}

	fmt.Println(resp.Choices[0].Message.Content)
	if resp.Usage != nil {
		fmt.Printf("токены: вход %d, выход %d\n", resp.Usage.PromptTokens, resp.Usage.CompletionTokens)
	}
}
Тело ответа при ошибке

Схема тела ошибки в документации не описана, поэтому его надёжнее прочитать целиком строкой и положить в текст ошибки — так причину видно в логах. Читайте с ограничением через io.LimitReader, чтобы не вытянуть в память большой ответ. Коды 401 и 402 повторять бессмысленно, а 429, 500 и 503 — имеет смысл.

Нужен API-ключ сегодня

Регистрация по email или Telegram занимает пару минут, оплата идёт в рублях, VPN не нужен. Один ключ работает и с DeepSeek, и с другими моделями.

Получить ключ

Потоковая выдача

В режиме stream: true сервер отвечает в формате SSE: строки вида data: {...} и завершающая data: [DONE]. Строки без префикса data: (в том числе комментарии : keep-alive) нужно пропускать.

go
package main

import (
	"bufio"
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"strings"
	"time"
)

// stream печатает ответ по мере генерации и возвращает расход токенов.
// stream_options.include_usage требует stream: true, иначе API вернёт 400.
func stream(ctx context.Context, prompt string) (*Usage, error) {
	body, err := json.Marshal(Request{
		Model:         model,
		Messages:      []Message{{Role: "user", Content: prompt}},
		Stream:        true,
		StreamOptions: &StreamOptions{IncludeUsage: true},
	})
	if err != nil {
		return nil, fmt.Errorf("marshal request: %w", err)
	}

	ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/chat/completions", bytes.NewReader(body))
	if err != nil {
		return nil, fmt.Errorf("new request: %w", err)
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Accept", "text/event-stream")
	req.Header.Set("Authorization", "Bearer "+os.Getenv("DEEPSEEK_API_KEY"))

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, fmt.Errorf("do request: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		detail, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
		return nil, fmt.Errorf("deepseek: status %d: %s", resp.StatusCode, bytes.TrimSpace(detail))
	}

	scanner := bufio.NewScanner(resp.Body)
	scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // чанк может быть крупным

	var usage *Usage
	for scanner.Scan() {
		line := strings.TrimSpace(scanner.Text())

		payload, ok := strings.CutPrefix(line, "data:")
		if !ok {
			continue // пустая строка или комментарий keep-alive
		}
		payload = strings.TrimSpace(payload)
		if payload == "[DONE]" {
			break
		}

		var chunk Chunk
		if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
			continue // неполный или служебный чанк пропускаем
		}
		if len(chunk.Choices) > 0 {
			fmt.Print(chunk.Choices[0].Delta.Content)
		}
		if chunk.Usage != nil {
			usage = chunk.Usage // приходит последним чанком
		}
	}
	if err := scanner.Err(); err != nil {
		return usage, fmt.Errorf("read stream: %w", err)
	}

	fmt.Println()
	return usage, nil
}

Альтернатива на том же теле ответа — декодер, который читает JSON-объекты подряд. Он не требует разбирать префикс data: вручную, но спотыкается на вставках вроде [DONE] и : keep-alive, поэтому такой цикл обычно комбинируют с bufio.Reader:

go
// Нужны импорты: bufio, encoding/json, errors, fmt, io
br := bufio.NewReader(resp.Body)
dec := json.NewDecoder(br)

for {
	var chunk Chunk
	if err := dec.Decode(&chunk); err != nil {
		if errors.Is(err, io.EOF) {
			break // поток закрыт штатно
		}
		return nil, fmt.Errorf("decode chunk: %w", err)
	}
	if len(chunk.Choices) > 0 {
		fmt.Print(chunk.Choices[0].Delta.Content)
	}
	if chunk.Usage != nil {
		usage = chunk.Usage
	}
}

Повторы с экспоненциальной задержкой

Коды 429, 500 и 503 означают, что запрос имеет смысл повторить. Следующая функция повторяет операцию, удваивая паузу и реагируя на отмену контекста, — например, когда пользователь закрыл страницу и запрос больше не нужен.

go
package main

import (
	"context"
	"fmt"
	"net/http"
	"time"
)

// doWithRetry повторяет операцию при 429, 500 и 503.
// isRetryable возвращает true, если ошибку имеет смысл повторить.
// maxAttempts включает первую попытку.
func doWithRetry(
	ctx context.Context,
	maxAttempts int,
	isRetryable func(error) bool,
	op func(ctx context.Context) (*http.Response, error),
) (*http.Response, error) {
	delay := time.Second

	for attempt := 1; attempt <= maxAttempts; attempt++ {
		resp, err := op(ctx)
		if err == nil && resp.StatusCode != http.StatusTooManyRequests && resp.StatusCode < 500 {
			return resp, nil
		}

		retryable := err == nil || isRetryable(err)
		if !retryable || attempt == maxAttempts {
			if err != nil {
				return nil, err
			}
			return resp, nil // отдаём последний ответ, разбор — на стороне вызывающего
		}
		if resp != nil {
			resp.Body.Close() // соединение не удерживаем, тело уже не нужно
		}

		fmt.Printf("попытка %d не удалась, пауза %s\n", attempt, delay)

		select {
		case <-ctx.Done():
			return nil, ctx.Err() // отмена или истёкший таймаут
		case <-time.After(delay):
		}
		delay *= 2
	}

	return nil, fmt.Errorf("deepseek: исчерпаны попытки")
}

Вызов выглядит так: операция собирает запрос заново на каждой попытке, потому что тело *http.Request можно прочитать только один раз.

go
// Константы baseURL, model и типы Request, Message, Response — из примеров выше.
func askWithRetry(ctx context.Context, prompt string) (*Response, error) {
	body, err := json.Marshal(Request{
		Model:    model,
		Messages: []Message{{Role: "user", Content: prompt}},
	})
	if err != nil {
		return nil, fmt.Errorf("marshal request: %w", err)
	}

	op := func(ctx context.Context) (*http.Response, error) {
		req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/chat/completions", bytes.NewReader(body))
		if err != nil {
			return nil, fmt.Errorf("new request: %w", err)
		}
		req.Header.Set("Content-Type", "application/json")
		req.Header.Set("Authorization", "Bearer "+os.Getenv("DEEPSEEK_API_KEY"))

		return http.DefaultClient.Do(req)
	}

	resp, err := doWithRetry(ctx, 5, func(err error) bool {
		// Транспортные ошибки и таймауты тоже имеет смысл повторить
		return true
	}, op)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		detail, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
		return nil, fmt.Errorf("deepseek: status %d: %s", resp.StatusCode, bytes.TrimSpace(detail))
	}

	var out Response
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return nil, fmt.Errorf("decode response: %w", err)
	}

	return &out, nil
}

Заголовка Retry-After в документации DeepSeek нет, поэтому паузу выбирает клиент. Задержку стоит ограничить сверху, добавить небольшой случайный разброс и помнить, что контекст с общим таймаутом ограничивает суммарное время всех попыток.

Расчёт стоимости по usage

В объекте usage видно, сколько входных токенов попало в контекстный кэш, а сколько нет. Ниже — расчёт для deepseek-flash по ценам вне пика, в долларах за 1M токенов.

go
package main

// Цены deepseek-flash вне пика, USD за 1M токенов.
const (
	priceCacheHit  = 0.003
	priceCacheMiss = 0.15
	priceOutput    = 0.6
)

// costUSD считает стоимость запроса в долларах.
func costUSD(u Usage) float64 {
	const million = 1_000_000.0

	return (float64(u.PromptCacheHitTokens)*priceCacheHit +
		float64(u.PromptCacheMissTokens)*priceCacheMiss +
		float64(u.CompletionTokens)*priceOutput) / million
}

// Использование:
// if usage != nil {
// 	fmt.Printf("стоимость запроса: $%.6f\n", costUSD(*usage))
// }

В часы пик (01:00–04:00 и 06:00–10:00 UTC по будням, по Москве это 04:00–07:00 и 09:00–13:00) цена вдвое выше — умножьте результат на два. Попадание входа в кэш снижает цену этих токенов в 50 раз, а completion_tokens включает токены рассуждений. Актуальные значения сверяйте на странице цен DeepSeek.

Через агрегатор

Код не меняется: достаточно заменить базовый адрес и ключ. Идентификаторы моделей у агрегаторов другие — точное имя нужно смотреть в каталоге сервиса.

go
// Ключ вида sk-aitunnel-... создаётся в личном кабинете агрегатора
const (
	BaseURL = "https://api.aitunnel.ru/v1/" // вместо https://api.deepseek.com
	Model   = "deepseek-v4.1-flash"         // идентификатор из каталога сервиса
)

func askTunnel(ctx context.Context, prompt string) (string, error) {
	body, err := json.Marshal(Request{
		Model:    Model,
		Messages: []Message{{Role: "user", Content: prompt}},
	})
	if err != nil {
		return "", fmt.Errorf("marshal request: %w", err)
	}

	ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, BaseURL+"chat/completions", bytes.NewReader(body))
	if err != nil {
		return "", fmt.Errorf("new request: %w", err)
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+os.Getenv("AITUNNEL_API_KEY"))

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", fmt.Errorf("do request: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		detail, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
		return "", fmt.Errorf("aitunnel: status %d: %s", resp.StatusCode, bytes.TrimSpace(detail))
	}

	var out Response
	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
		return "", fmt.Errorf("decode response: %w", err)
	}
	if len(out.Choices) == 0 {
		return "", fmt.Errorf("aitunnel: в ответе нет choices")
	}

	return out.Choices[0].Message.Content, nil
}

Обратите внимание на имя модели: в официальном API DeepSeek это deepseek-flash, а на AITUNNEL та же модель называется иначе — например, deepseek-v4.1-flash. Стриминг, повторы и расчёт стоимости работают через агрегатор без изменений.

Подробный разбор подключения через AITUNNEL →

Нужен API-ключ сегодня

Регистрация по email или Telegram занимает пару минут, оплата идёт в рублях, VPN не нужен. Один ключ работает и с DeepSeek, и с другими моделями.

Получить ключ

Читайте также

Один ключ вместо пяти аккаунтов

DeepSeek, GPT, Claude и Gemini через один OpenAI-совместимый адрес: api.aitunnel.ru/v1. Регистрация занимает пару минут, оплата в рублях, доступ без VPN.

  • Один ключ на все модели
  • Оплата в рублях и счёт для юрлиц
  • Совместимо с OpenAI SDK