diff --git a/packages/opencode/src/provider/nanogpt.ts b/packages/opencode/src/provider/nanogpt.ts new file mode 100644 index 000000000000..07a3fb6247d8 --- /dev/null +++ b/packages/opencode/src/provider/nanogpt.ts @@ -0,0 +1,40 @@ +import type { MetadataExtractor } from "@ai-sdk/openai-compatible" +import { isRecord } from "@/util/record" + +const nonNegative = (value: unknown) => + typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined + +function extract(value: unknown) { + if (!isRecord(value)) return {} + const count = isRecord(value.usage) ? nonNegative(value.usage.cache_creation_input_tokens) : undefined + const writes = Number.isSafeInteger(count) ? count : undefined + const cost = + isRecord(value.x_nanogpt_pricing) && value.x_nanogpt_pricing.currency === "USD" + ? nonNegative(value.x_nanogpt_pricing.amount) + : undefined + return { + ...(writes === undefined ? {} : { cacheCreationInputTokens: writes }), + ...(cost === undefined ? {} : { costUSD: cost }), + } +} + +export const metadataExtractor: MetadataExtractor = { + async extractMetadata({ parsedBody }) { + return { nanogpt: extract(parsedBody) } + }, + createStreamExtractor() { + let metadata: ReturnType = {} + return { + processChunk(chunk) { + // Usage and settled pricing can arrive in separate frames. Explicit zero + // replaces an earlier value; absent or invalid fields leave it intact. + metadata = { ...metadata, ...extract(chunk) } + }, + buildMetadata() { + return { nanogpt: metadata } + }, + } + }, +} + +export * as NanoGPT from "./nanogpt" diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 867ef2ca2fa8..787805eed1f8 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -31,6 +31,7 @@ import { ModelV2 } from "@opencode-ai/core/model" import { ModelStatus } from "./model-status" import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderError } from "./error" +import { NanoGPT } from "./nanogpt" const OPENAI_HEADER_TIMEOUT_DEFAULT = 300_000 @@ -1736,6 +1737,10 @@ const layer = Layer.effect( const provider = s.providers[model.providerID] const options = { ...provider.options } + if (model.providerID === "nano-gpt" && model.api.npm === "@ai-sdk/openai-compatible") { + options.metadataExtractor = NanoGPT.metadataExtractor + } + if ( model.providerID === "google-vertex" && model.api.npm === "@ai-sdk/google-vertex/anthropic" && diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index a2a91cd47b5e..e044b06ee599 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -343,9 +343,23 @@ export const getUsage = (input: { model: Provider.Model; usage: Usage; metadata? const reasoningTokens = safe(input.usage.reasoningTokens ?? 0) const cacheReadInputTokens = safe(input.usage.cacheReadInputTokens ?? 0) + const nanogpt = input.model.providerID === "nano-gpt" ? input.metadata?.["nanogpt"] : undefined + const writes = nanogpt?.["cacheCreationInputTokens"] + const nanogptWrites = + typeof writes === "number" && + Number.isSafeInteger(writes) && + writes >= 0 && + writes <= inputTokens - cacheReadInputTokens + ? writes + : undefined + const costUSD = nanogpt?.["costUSD"] + const nanogptCost = typeof costUSD === "number" && Number.isFinite(costUSD) && costUSD >= 0 ? costUSD : undefined + const cacheWriteInputTokens = safe( Number( - input.usage.cacheWriteInputTokens ?? + // Compatible SDKs can normalize unsupported cache writes to zero. + nanogptWrites ?? + input.usage.cacheWriteInputTokens ?? input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ?? // google-vertex-anthropic returns metadata under "vertex" key // (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages') @@ -387,7 +401,8 @@ export const getUsage = (input: { model: Provider.Model; usage: Usage; metadata? const totalNanoAiu = input.metadata?.["copilot"]?.["totalNanoAiu"] return { cost: - typeof totalNanoAiu === "number" && Number.isFinite(totalNanoAiu) && totalNanoAiu >= 0 + nanogptCost ?? + (typeof totalNanoAiu === "number" && Number.isFinite(totalNanoAiu) && totalNanoAiu >= 0 ? new Decimal(totalNanoAiu).div(100_000_000_000).toNumber() : safe( new Decimal(0) @@ -399,7 +414,7 @@ export const getUsage = (input: { model: Provider.Model; usage: Usage; metadata? // charge reasoning tokens at the same rate as output tokens .add(new Decimal(tokens.reasoning).mul(finite(costInfo?.output ?? 0)).div(1_000_000)) .toNumber(), - ), + )), tokens, } } diff --git a/packages/opencode/test/provider/nanogpt.test.ts b/packages/opencode/test/provider/nanogpt.test.ts new file mode 100644 index 000000000000..c97ec9860ec7 --- /dev/null +++ b/packages/opencode/test/provider/nanogpt.test.ts @@ -0,0 +1,122 @@ +import { expect, test } from "bun:test" +import { createOpenAICompatible } from "@ai-sdk/openai-compatible" +import { generateText, streamText } from "ai" +import { Effect } from "effect" +import { Usage } from "@opencode-ai/llm" +import { NanoGPT } from "@/provider/nanogpt" +import { Provider } from "@/provider/provider" +import { Session } from "@/session/session" +import { LLMAISDK } from "@/session/llm/ai-sdk" + +const model = { + id: "test-model", + providerID: "nano-gpt", + cost: { input: 2, output: 10, cache: { read: 0.2, write: 2.5 } }, +} as Provider.Model +const usage = { + prompt_tokens: 1000, + completion_tokens: 20, + total_tokens: 1020, + cache_creation_input_tokens: 800, + prompt_tokens_details: { cached_tokens: 100 }, +} +const terminal = (extra: Record = {}) => ({ + id: "test-response", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage, + ...extra, +}) +async function account(frames: unknown[]) { + const sdk = createOpenAICompatible({ + name: "nano-gpt", + baseURL: "https://example.invalid/v1", + metadataExtractor: NanoGPT.metadataExtractor, + fetch: Object.assign( + async () => + new Response(frames.map((frame) => `data: ${JSON.stringify(frame)}\n\n`).join("") + "data: [DONE]\n\n", { + headers: { "content-type": "text/event-stream" }, + }), + { preconnect() {} }, + ), + }) + const result = streamText({ model: sdk("test-model"), prompt: "Hello", maxRetries: 0 }) + const state = LLMAISDK.adapterState() + for await (const part of result.fullStream) { + for (const event of await Effect.runPromise(LLMAISDK.toLLMEvents(state, part))) { + if (event.type !== "step-finish") continue + return Session.getUsage({ model, usage: event.usage!, metadata: event.providerMetadata }) + } + } + throw new Error("Missing step-finish") +} +test("accounts for streamed writes and settled USD cost through the SDK adapter", async () => { + const result = await account([terminal(), { choices: [], x_nanogpt_pricing: { amount: 0.0123, currency: "USD" } }]) + expect(result.tokens).toMatchObject({ input: 100, output: 20, cache: { read: 100, write: 800 } }) + expect(result.cost).toBe(0.0123) +}) +test("extracts non-streaming metadata through the compatible SDK", async () => { + const sdk = createOpenAICompatible({ + name: "nano-gpt", + baseURL: "https://example.invalid/v1", + metadataExtractor: NanoGPT.metadataExtractor, + fetch: Object.assign( + async () => + Response.json({ + id: "test-response", + created: 1, + model: "test-model", + choices: [{ index: 0, message: { role: "assistant", content: "Hello" }, finish_reason: "stop" }], + usage, + x_nanogpt_pricing: { amount: 0, currency: "USD" }, + }), + { preconnect() {} }, + ), + }) + const result = await generateText({ model: sdk("test-model"), prompt: "Hello", maxRetries: 0 }) + expect(result.providerMetadata?.nanogpt).toEqual({ cacheCreationInputTokens: 800, costUSD: 0 }) + expect( + Session.getUsage({ + model, + usage: new Usage({ inputTokens: 1000, outputTokens: 20, cacheReadInputTokens: 100 }), + metadata: result.providerMetadata, + }).cost, + ).toBe(0) +}) +test("preserves zero, ignores malformed metadata, and isolates streams", () => { + const first = NanoGPT.metadataExtractor.createStreamExtractor() + const second = NanoGPT.metadataExtractor.createStreamExtractor() + first.processChunk(terminal({ x_nanogpt_pricing: { amount: 0.5, currency: "USD" } })) + first.processChunk({ usage: { cache_creation_input_tokens: 0 }, x_nanogpt_pricing: { amount: 0, currency: "USD" } }) + for (const invalid of [-1, Infinity, NaN, "12", null, {}, []]) + first.processChunk({ + usage: { cache_creation_input_tokens: invalid }, + x_nanogpt_pricing: { amount: invalid, currency: "USD" }, + }) + first.processChunk({ + usage: { cache_creation_input_tokens: 1.5 }, + x_nanogpt_pricing: { amount: 10, currency: "NANO" }, + }) + expect(first.buildMetadata()).toEqual({ nanogpt: { cacheCreationInputTokens: 0, costUSD: 0 } }) + expect(second.buildMetadata()).toEqual({ nanogpt: {} }) +}) +test("falls back to configured rates when settled USD cost is unavailable", async () => { + const result = await account([terminal({ x_nanogpt_pricing: { amount: 1, currency: "NANO" } })]) + expect(result.cost).toBeCloseTo(0.00242, 10) +}) +test("scopes metadata to NanoGPT and rejects inconsistent cache counts", () => { + const input = new Usage({ inputTokens: 1000, outputTokens: 20, cacheReadInputTokens: 100, cacheWriteInputTokens: 0 }) + const metadata = { nanogpt: { cacheCreationInputTokens: 800, costUSD: 0 } } + expect(Session.getUsage({ model, usage: input, metadata }).tokens.cache.write).toBe(800) + const other = Session.getUsage({ model: { ...model, providerID: "other" } as Provider.Model, usage: input, metadata }) + expect(other.tokens.cache.write).toBe(0) + expect(other.cost).toBeCloseTo(0.00202, 10) + for (const invalid of [901, -1, Infinity, NaN, "800", 1.5]) { + const result = Session.getUsage({ + model, + usage: input, + metadata: { nanogpt: { cacheCreationInputTokens: invalid, costUSD: -1 } }, + }) + expect(result.tokens.cache.write).toBe(0) + expect(result.cost).toBeCloseTo(0.00202, 10) + } +}) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 32d4e3a39b10..80d7d8b58da5 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -2116,3 +2116,40 @@ it.effect("opencode loader keeps paid models when auth exists", () => expect(keyedCount).toBeGreaterThan(0) }).pipe(provideMultiInstance), ) + +it.instance( + "NanoGPT registers the compatible SDK metadata extractor", + () => + Effect.gen(function* () { + const provider = yield* Provider.Service + const model = yield* provider.getModel(ProviderV2.ID.make("nano-gpt"), ModelV2.ID.make("test-model")) + const language = yield* provider.getLanguage(model) + const config = ( + language as { + config?: { metadataExtractor?: { extractMetadata: (input: { parsedBody: unknown }) => Promise } } + } + ).config + expect(config?.metadataExtractor).toBeDefined() + const metadata = yield* Effect.promise(() => + config!.metadataExtractor!.extractMetadata({ + parsedBody: { + usage: { cache_creation_input_tokens: 50 }, + x_nanogpt_pricing: { amount: 0.01, currency: "USD" }, + }, + }), + ) + expect(metadata).toEqual({ nanogpt: { cacheCreationInputTokens: 50, costUSD: 0.01 } }) + }), + { + config: { + provider: { + "nano-gpt": { + npm: "@ai-sdk/openai-compatible", + api: "https://example.invalid/v1", + options: { apiKey: "test-key" }, + models: { "test-model": { name: "Test model" } }, + }, + }, + }, + }, +)