Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions packages/opencode/src/provider/nanogpt.ts
Original file line number Diff line number Diff line change
@@ -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<typeof extract> = {}
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"
5 changes: 5 additions & 0 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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" &&
Expand Down
21 changes: 18 additions & 3 deletions packages/opencode/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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)
Expand All @@ -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,
}
}
Expand Down
122 changes: 122 additions & 0 deletions packages/opencode/test/provider/nanogpt.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) => ({
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)
}
})
37 changes: 37 additions & 0 deletions packages/opencode/test/provider/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown> } }
}
).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" } },
},
},
},
},
)
Loading