From 87af43b824b836b5ef07d8434dc1cb80558eab0a Mon Sep 17 00:00:00 2001 From: Alex-wuhu Date: Wed, 4 Mar 2026 12:38:57 +0800 Subject: [PATCH 1/2] feat: implement Novita AI integration --- .env.example | 1 + common/src/constants/byok.ts | 1 + packages/internal/src/env-schema.ts | 2 + packages/internal/src/env.ts | 1 + sdk/src/env.ts | 9 +- sdk/src/impl/model-provider.ts | 32 +++- web/src/app/api/v1/chat/completions/_post.ts | 57 ++++--- web/src/llm-api/novita.ts | 164 +++++++++++++++++++ 8 files changed, 244 insertions(+), 23 deletions(-) create mode 100644 web/src/llm-api/novita.ts diff --git a/.env.example b/.env.example index 5ac7df6c31..0df3793cd1 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ CLAUDE_CODE_KEY=dummy_claude_code_key OPEN_ROUTER_API_KEY=dummy_openrouter_key OPENAI_API_KEY=dummy_openai_key +NOVITA_API_KEY=dummy_novita_key ANTHROPIC_API_KEY=dummy_anthropic_key # Database & Server diff --git a/common/src/constants/byok.ts b/common/src/constants/byok.ts index 50640d41e1..a847487dc0 100644 --- a/common/src/constants/byok.ts +++ b/common/src/constants/byok.ts @@ -1,2 +1,3 @@ export const BYOK_OPENROUTER_HEADER = 'x-openrouter-api-key' export const BYOK_OPENROUTER_ENV_VAR = 'CODEBUFF_BYOK_OPENROUTER' +export const BYOK_NOVITA_ENV_VAR = 'CODEBUFF_BYOK_NOVITA' diff --git a/packages/internal/src/env-schema.ts b/packages/internal/src/env-schema.ts index 2173b6e80a..03bdf071d3 100644 --- a/packages/internal/src/env-schema.ts +++ b/packages/internal/src/env-schema.ts @@ -5,6 +5,7 @@ export const serverEnvSchema = clientEnvSchema.extend({ // LLM API keys OPEN_ROUTER_API_KEY: z.string().min(1), OPENAI_API_KEY: z.string().min(1), + NOVITA_API_KEY: z.string().min(1), ANTHROPIC_API_KEY: z.string().min(1), LINKUP_API_KEY: z.string().min(1), CONTEXT7_API_KEY: z.string().optional(), @@ -47,6 +48,7 @@ export const serverProcessEnv: ServerInput = { // LLM API keys OPEN_ROUTER_API_KEY: process.env.OPEN_ROUTER_API_KEY, OPENAI_API_KEY: process.env.OPENAI_API_KEY, + NOVITA_API_KEY: process.env.NOVITA_API_KEY, ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, LINKUP_API_KEY: process.env.LINKUP_API_KEY, CONTEXT7_API_KEY: process.env.CONTEXT7_API_KEY, diff --git a/packages/internal/src/env.ts b/packages/internal/src/env.ts index 501766f93c..5b4c2a5637 100644 --- a/packages/internal/src/env.ts +++ b/packages/internal/src/env.ts @@ -13,6 +13,7 @@ if (isCI) { ensureEnvDefault('OPEN_ROUTER_API_KEY', 'test') ensureEnvDefault('OPENAI_API_KEY', 'test') + ensureEnvDefault('NOVITA_API_KEY', 'test') ensureEnvDefault('ANTHROPIC_API_KEY', 'test') ensureEnvDefault('LINKUP_API_KEY', 'test') ensureEnvDefault('GRAVITY_API_KEY', 'test') diff --git a/sdk/src/env.ts b/sdk/src/env.ts index 325059acdf..31e4b8c246 100644 --- a/sdk/src/env.ts +++ b/sdk/src/env.ts @@ -5,7 +5,10 @@ * process env with SDK-specific vars for binary paths and WASM. */ -import { BYOK_OPENROUTER_ENV_VAR } from '@codebuff/common/constants/byok' +import { + BYOK_OPENROUTER_ENV_VAR, + BYOK_NOVITA_ENV_VAR, +} from '@codebuff/common/constants/byok' import { CLAUDE_OAUTH_TOKEN_ENV_VAR } from '@codebuff/common/constants/claude-oauth' import { API_KEY_ENV_VAR } from '@codebuff/common/constants/paths' import { getBaseEnv } from '@codebuff/common/env-process' @@ -42,6 +45,10 @@ export const getByokOpenrouterApiKeyFromEnv = (): string | undefined => { return process.env[BYOK_OPENROUTER_ENV_VAR] } +export const getByokNovitaApiKeyFromEnv = (): string | undefined => { + return process.env[BYOK_NOVITA_ENV_VAR] +} + /** * Get Claude OAuth token from environment variable. * This allows users to provide their Claude Pro/Max OAuth token for direct Anthropic API access. diff --git a/sdk/src/impl/model-provider.ts b/sdk/src/impl/model-provider.ts index 797d13daf3..3325c97fd3 100644 --- a/sdk/src/impl/model-provider.ts +++ b/sdk/src/impl/model-provider.ts @@ -24,9 +24,13 @@ import { import { WEBSITE_URL } from '../constants' import { getValidClaudeOAuthCredentials } from '../credentials' -import { getByokOpenrouterApiKeyFromEnv } from '../env' +import { + getByokOpenrouterApiKeyFromEnv, + getByokNovitaApiKeyFromEnv, +} from '../env' import type { LanguageModel } from 'ai' +import { createOpenAICompatible } from '@codebuff/internal/openai-compatible/index' // ============================================================================ // Claude OAuth Rate Limit Cache @@ -188,12 +192,38 @@ export async function getModelForRequest(params: ModelRequestParams): Promise = { + 'deepseek/deepseek-r1': 0.6, + 'deepseek/deepseek-v3': 0.6, + 'meta-llama/llama-3.3-70b-instruct': 0.6, + 'default': 0.6, +} as const + +const OUTPUT_TOKEN_COSTS: Record = { + 'deepseek/deepseek-r1': 2.4, + 'deepseek/deepseek-v3': 2.4, + 'meta-llama/llama-3.3-70b-instruct': 2.4, + 'default': 2.4, +} as const + +function extractUsageAndCost( + usage: any, + model: string, +): UsageData { + const inputTokenCost = INPUT_TOKEN_COSTS[model] ?? INPUT_TOKEN_COSTS['default'] + const outputTokenCost = OUTPUT_TOKEN_COSTS[model] ?? OUTPUT_TOKEN_COSTS['default'] + + const inTokens = usage.prompt_tokens ?? 0 + const outTokens = usage.completion_tokens ?? 0 + const cost = + (inTokens / 1_000_000) * inputTokenCost + + (outTokens / 1_000_000) * outputTokenCost + + return { + inputTokens: inTokens, + outputTokens: outTokens, + cacheReadInputTokens: 0, + reasoningTokens: usage.completion_tokens_details?.reasoning_tokens ?? 0, + cost, + } +} + +export async function handleNovitaNonStream({ + body, + userId, + stripeCustomerId, + agentId, + fetch, + logger, + insertMessageBigquery, +}: { + body: ChatCompletionRequestBody + userId: string + stripeCustomerId?: string | null + agentId: string + fetch: typeof globalThis.fetch + logger: Logger + insertMessageBigquery: InsertMessageBigqueryFn +}) { + const startTime = new Date() + const { clientId, clientRequestId, costMode } = extractRequestMetadata({ + body, + logger, + }) + + const { model } = body + // model is something like "novita/deepseek/deepseek-r1" + const novitaModel = model.startsWith('novita/') ? model.slice(7) : model + + // Build Novita-compatible body + const novitaBody: Record = { + ...body, + model: novitaModel, + stream: false, + } + + // Remove fields that Novita/OpenAI doesn't support + delete novitaBody.usage + delete novitaBody.provider + delete novitaBody.transforms + delete novitaBody.codebuff_metadata + + const response = await fetch('https://api.novita.ai/openai/chat/completions', { + method: 'POST', + headers: { + Authorization: `Bearer ${env.NOVITA_API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(novitaBody), + }) + + if (!response.ok) { + throw new Error( + `Novita API error: ${response.status} ${response.statusText} ${await response.text()}`, + ) + } + + const data = await response.json() + + const usage = data.usage ?? {} + const usageData = extractUsageAndCost(usage, novitaModel) + + data.usage.cost = usageData.cost + data.usage.cost_details = { upstream_inference_cost: null } + + const responseContents: string[] = [] + if (data.choices && Array.isArray(data.choices)) { + for (const choice of data.choices) { + responseContents.push(choice.message?.content ?? '') + } + } + const responseText = JSON.stringify(responseContents) + const reasoningText = data.choices?.[0]?.message?.reasoning_content ?? '' + + insertMessageToBigQuery({ + messageId: data.id, + userId, + startTime, + request: body, + reasoningText, + responseText, + usageData, + logger, + insertMessageBigquery, + }).catch((error) => { + logger.error({ error }, 'Failed to insert message into BigQuery (Novita)') + }) + + await consumeCreditsForMessage({ + messageId: data.id, + userId, + stripeCustomerId, + agentId, + clientId, + clientRequestId, + startTime, + model: data.model, + reasoningText, + responseText, + usageData, + byok: false, + logger, + costMode, + }) + + return { + ...data, + choices: [ + { + index: 0, + message: { content: responseContents[0] ?? '', role: 'assistant' }, + finish_reason: 'stop', + }, + ], + } +} From 99b6c66d280556483a2135bcd177330f206fca40 Mon Sep 17 00:00:00 2001 From: Alex-wuhu Date: Wed, 4 Mar 2026 16:36:08 +0800 Subject: [PATCH 2/2] fix: correct Novita model pricing and add missing models - deepseek-v3 pricing: input /bin/bash.269/M, output /bin/bash.4/M - Add deepseek-v3.2 as primary model name - Add glm-5 and minimax-m2.5 pricing - Keep deepseek-v3 as alias for backwards compatibility --- web/src/llm-api/novita.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/web/src/llm-api/novita.ts b/web/src/llm-api/novita.ts index d62ad2c685..b2469e3b93 100644 --- a/web/src/llm-api/novita.ts +++ b/web/src/llm-api/novita.ts @@ -11,17 +11,23 @@ import type { InsertMessageBigqueryFn } from '@codebuff/common/types/contracts/b import type { Logger } from '@codebuff/common/types/contracts/logger' import type { ChatCompletionRequestBody } from './types' -// Novita pricing (approximate, adjust as needed) +// Novita pricing ($/M tokens, based on Novita pricing page) const INPUT_TOKEN_COSTS: Record = { 'deepseek/deepseek-r1': 0.6, - 'deepseek/deepseek-v3': 0.6, + 'deepseek/deepseek-v3.2': 0.269, + 'deepseek/deepseek-v3': 0.269, // alias + 'zai-org/glm-5': 1.0, + 'minimax/minimax-m2.5': 0.3, 'meta-llama/llama-3.3-70b-instruct': 0.6, 'default': 0.6, } as const const OUTPUT_TOKEN_COSTS: Record = { 'deepseek/deepseek-r1': 2.4, - 'deepseek/deepseek-v3': 2.4, + 'deepseek/deepseek-v3.2': 0.4, + 'deepseek/deepseek-v3': 0.4, // alias + 'zai-org/glm-5': 3.2, + 'minimax/minimax-m2.5': 1.2, 'meta-llama/llama-3.3-70b-instruct': 2.4, 'default': 2.4, } as const