Мобильное приложение на Claude или GPT API
Ключ, вшитый в мобильное приложение, рано или поздно окажется в чужих руках. Разбираем архитектуру с бэкендом-прокси и реальные примеры на Kotlin, Swift и Node.
4 мин · · Обновлено · разработка
Первый вопрос при подключении LLM к мобильному приложению — не «какую модель выбрать», а «где будет жить ключ». Ответ на него определяет всю остальную архитектуру.
Ключ не должен попадать в APK или IPA
fetch_... — это полноценный доступ к балансу счёта. Если зашить его в код приложения, любой человек с apktool или простым MITM-прокси достанет его за несколько минут, и дальше расход пойдёт с вашего баланса, а не от пользователей приложения. Правильная схема — тонкий бэкенд между приложением и api.zukko.pro: мобильный клиент общается только со своим сервером, а сервер уже держит ключ в переменных окружения и делает запрос к шлюзу.
[Android / iOS] → [ваш backend] → https://api.zukko.pro/v1/...
Это не лишний слой ради лишнего слоя: на бэкенде удобно считать расход по пользователям, применять свои лимиты, кэшировать частые запросы и не пересобирать приложение при смене модели.
Backend: минимальный прокси на Node
// server.js
import express from "express";
const app = express();
app.use(express.json());
app.post("/api/ask", async (req, res) => {
const { message, userId, requestId } = req.body;
const r = await fetch("https://api.zukko.pro/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.ZUKKO_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": `${userId}-${requestId}`
},Idempotency-Key здесь не декоративный: на мобильной сети запросы обрываются и повторяются гораздо чаще, чем на десктопе. Один и тот же ключ идемпотентности в течение 24 часов гарантирует, что повторная отправка из-за таймаута не спишет деньги дважды. Поэтому ключ строится не из времени на сервере (тогда каждый повтор получил бы новый ключ), а из requestId, который приложение генерирует один раз на каждый логический запрос и отправляет без изменений при всех повторах. Ключ задаётся в заголовках конкретного запроса, а не в общих настройках клиента — иначе второй, уже другой запрос получит 422 idempotency_conflict.
iOS: вызов своего бэкенда
func ask(_ message: String, requestId: String) async throws -> String {
var request = URLRequest(url: URL(string: "https://your-backend.example.com/api/ask")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(["message": message, "userId": currentUserId, "requestId": requestId])
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw AppError.serverError
}
let decoded = try JSONDecoder().decode(AskResponse.self, from: data)
return decoded.reply
}Android: то же самое на Kotlin
suspend fun ask(message: String, userId: String, requestId: String): String {
val body = JSONObject().apply {
put("message", message)
put("userId", userId)
put("requestId", requestId)
}.toString().toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url("https://your-backend.example.com/api/ask")
.post(body)
.build()
okHttpClient.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) throw IOException("server error: ${resp.code}")
val json = JSONObject(resp.body!!.string())
return json.getString("reply")
}
}Фото с камеры — используйте vision без отдельного «режима»
Модели Claude и GPT в каталоге принимают изображения — отдельной «vision-модели» выбирать не нужно, claude-sonnet-5 и gpt-5.6-sol читают картинки наравне с текстом. У glm-5.3 и glm-5.3-flash vision нет, так что запросы с фото на них не отправляйте. Для сценария «сфотографируй и спроси» кодируйте снимок в base64 на устройстве и отправляйте на свой бэкенд, а бэкенд уже собирает нужную схему для /v1/messages или /v1/chat/completions. Перед отправкой тяжёлого фото по мобильному интернету имеет смысл прогнать его через бесплатный /v1/messages/count_tokens, чтобы прикинуть стоимость запроса до того, как он ушёл.
Ограничьте ущерб на уровне ключа, а не только в коде
На странице ключа в кабинете (https://zukko.pro/app/api-keys) можно задать дневной и месячный лимит расхода в долларах и список разрешённых моделей. Для мобильного бэкенда это первая линия защиты: даже если в приложении окажется баг, который зациклит запросы, счётчик остановится на выставленном лимите, а не на исчерпании всего баланса.
Обработка ошибок, специфичных для мобильного трафика
- 429 с
key_daily_limit_reachedилиkey_monthly_limit_reached— это не общий лимит сервиса, а лимит именно вашего ключа. Отличайте эту ошибку от «модель перегружена» в интерфейсе приложения — пользователю нужно разное сообщение. - 402 insufficient_balance — баланс закончился до того, как запрос ушёл к модели, так что неудачная попытка ничего не стоит. Стоит показывать это отдельным экраном, а не общей ошибкой сети.
- Обрыв соединения на слабой сети — почти всегда таймаут клиента, а не лимит по токенам. На мобильных стоит держать таймаут HTTP-клиента заметно выше, чем на десктопе, и полагаться на
Idempotency-Keyпри повторных попытках.
Счётчик расхода на пользователя
GET /v1/usage отдаёт историю списаний с курсорной пагинацией — общую на аккаунт, без разбивки по ключам и тем более по вашим пользователям. А каждый обычный (нестриминговый) ответ на /v1/chat/completions или /v1/messages несёт заголовки X-Zukko-Cost-Micros (стоимость именно этого запроса в микродолларах) и X-Fetch-Balance-Cents (остаток баланса после списания — несмотря на название, тоже в микродолларах); у потоковых ответов этих заголовков нет. Поэтому счётчик на пользователя проще всего вести самому: писать X-Zukko-Cost-Micros в свою таблицу расходов сразу на бэкенде при каждом ответе — тогда не нужно ничего досчитывать отдельным job'ом, а раз в день сверять общую сумму с /v1/usage.
Пополнение баланса для продакшена
Для боевого сервера полезно завести отдельный административный процесс пополнения баланса, не завязанный на карту конкретного разработчика: кабинет принимает СБП, банковскую карту через PayPear, крипту и — с 2026-09-16 — иностранные карты Mastercard EU через Fride (комиссия около 13%, суммы $2–$594). Минимальное пополнение — $1. Учтите, что стартового баланса при обычной регистрации больше нет: эту практику отменили 2026-09-18 из-за массовой регистрации на одноразовые почты, так что тестовый ключ для CI или стейджинга придётся пополнить вручную, прежде чем гонять по нему автотесты. Единственный бесплатный способ получить немного баланса — подписаться на Telegram-канал @zukkopro, это даёт разовый бонус $0.50 после привязки Telegram в кабинете.
Поддержка и вопросы по интеграции
Если что-то в ответе шлюза не сходится с документацией или бэкенд стабильно получает неожиданный код ошибки, быстрее всего описать конкретный запрос и ответ в поддержке через Telegram https://t.me/zukkopro?direct, приложив тело запроса без самого ключа. Для мобильного проекта, где баги на проде видно не сразу, такой прямой канал часто быстрее, чем перебор гипотез по логам в одиночку.
zukko
Платите в 25×
меньше
за Claude Code & ChatGPT
Один ключ fetch_* и base URL api.zukko.pro — в Cursor, Claude Code, Codex и любом SDK.
Похожие статьи
- Промпт-инъекция: что это и как защитить бота и агентаОбъясняем, как работает промпт-инъекция через пользовательский ввод и чужие документы, и какие меры снижают ущерб: изоляция данных, права инструментов, лимиты ключа.
- Свой прокси к нейросети на Node.js: не показывать API-ключ во фронтендеНебольшой сервер на Express между вашим фронтендом и API нейросети: ключ остаётся на сервере, а модели, длина запроса и частота ограничены.
- DeepSeek API на Python: запросы, стриминг, JSON и обработка ошибокГотовый код для DeepSeek V4.1 Flash и V4 Pro на OpenAI SDK: запрос, стриминг, разбор JSON и аккуратная обработка ошибок.
- Промпт-инжиниринг: примеры и 12 готовых шаблонов для работыДвенадцать шаблонов промптов для рабочих задач: что в них написано, почему именно так и сколько стоит один вызов по API.