diff --git a/packages/core/src/kilocode/gateway-rpc.ts b/packages/core/src/kilocode/gateway-rpc.ts index 01b3e7b099b6..2c5ae1b2ae17 100644 --- a/packages/core/src/kilocode/gateway-rpc.ts +++ b/packages/core/src/kilocode/gateway-rpc.ts @@ -46,8 +46,15 @@ export const ModelEntry = Schema.Struct({ export type ModelEntry = typeof ModelEntry.Type +export const SessionMetadata = Schema.Struct({ + routedModelID: Schema.optional(Schema.String), + vercelID: Schema.optional(Schema.String), +}) + +export type SessionMetadata = typeof SessionMetadata.Type + // The TUI and IDE clients use this contract for the account picker after login, /teams, -// and Kilo-specific model catalog metadata. +// Kilo-specific model catalog metadata, and per-session routed model metadata. export const Definition = Rpc.define({ id: "kilocode.gateway", methods: { @@ -66,6 +73,11 @@ export const Definition = Rpc.define({ output: portable(Schema.Array(ModelEntry)), errors: Failure, }, + "session.metadata": { + input: portable(Schema.Struct({ sessionID: Schema.String })), + output: portable(SessionMetadata), + errors: Failure, + }, }, events: { organization: { schema: portable(Schema.Struct({ organizationID: OrganizationID })) }, diff --git a/packages/core/src/kilocode/gateway.ts b/packages/core/src/kilocode/gateway.ts index 5165a6455ca1..7014a836be06 100644 --- a/packages/core/src/kilocode/gateway.ts +++ b/packages/core/src/kilocode/gateway.ts @@ -2,10 +2,15 @@ import { KiloAccount, KiloDefaults, KiloDevice, + KiloErrors, KiloHeaders, + KiloMachine, KiloModels, KiloProfile, + KiloProject, + KiloRequests, KiloServer, + KiloStream, } from "@kilocode/gateway-core" import type { ConnectionInfo } from "@opencode/client" import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration" @@ -15,13 +20,14 @@ import { IntegrationID, IntegrationMethodID } from "@opencode/schema/integration import { Model } from "@opencode/schema/model" import { Money } from "@opencode/schema/money" import { Provider } from "@opencode/schema/provider" +import { Global } from "@opencode/util/global" import { createHash, randomUUID } from "node:crypto" import { Clock, Duration, Effect, Fiber, Option, Schedule, Schema, Scope, Semaphore, Stream } from "effect" import { Bus } from "../bus.js" import { Variant } from "../variant.js" import { KiloGatewayRpc } from "./gateway-rpc.js" -// v2 adapter for the Kilo Gateway (issue #14991, PR 1 & PR 2: login, teams, and catalog authority). +// v2 adapter for the Kilo Gateway (issue #14991, PRs 1-3: login, teams, catalog authority, requests, and errors). // Kilo logic lives in @kilocode/gateway-core; this file maps it onto the v2 plugin API. const id = Provider.ID.make("kilo") const integrationID = IntegrationID.make("kilo") @@ -76,7 +82,25 @@ export const KiloGatewayPlugin = define({ anonymous: true, } const lastGood = new Map() + const sessionMetadata = new Map() let retryFiber: Fiber.Fiber | undefined + let cachedProjectID: string | null | undefined + let cachedMachineID: string | null | undefined + + const getProjectID = Effect.gen(function* () { + if (cachedProjectID !== undefined) return cachedProjectID ?? undefined + const resolved = yield* KiloProject.resolve(ctx.location.directory) + cachedProjectID = resolved ?? null + return resolved + }) + + const getMachineID = Effect.gen(function* () { + if (process.env.KILO_MACHINE_ID?.trim()) return process.env.KILO_MACHINE_ID.trim() + if (cachedMachineID !== undefined) return cachedMachineID ?? undefined + const resolved = yield* KiloMachine.get(Global.Path.data) + cachedMachineID = resolved ?? null + return resolved + }) const cancelRetry = Effect.gen(function* () { if (!retryFiber) return @@ -403,6 +427,80 @@ export const KiloGatewayPlugin = define({ } }) + // Per-request attribution headers (`X-KILOCODE-TASKID`, `X-KILOCODE-PARENT-TASKID`, `X-KILOCODE-PROJECTID`, + // `X-KILOCODE-MACHINEID`, `x-kilocode-mode`, `User-Agent`, plus provider headers). + yield* ctx.session.hook( + "model.request", + (event) => + Effect.gen(function* () { + const [session, projectID, machineID] = yield* Effect.all( + [ + ctx.session.get({ sessionID: event.sessionID }).pipe(Effect.option), + getProjectID, + getMachineID, + ], + { concurrency: 3 }, + ) + const parentSessionID = Option.isSome(session) ? session.value.parentID : undefined + const requestHeaders = KiloHeaders.request({ + version: ctx.app.version, + organizationID: catalog.account?.organizationID, + sessionID: event.sessionID, + parentSessionID, + projectID, + machineID, + mode: event.agent, + }) + Object.assign(event.headers, requestHeaders) + }), + { providerID: id }, + ) + + // Request body and auth normalization (`x-api-key` -> `Bearer`, credential metadata stripping, + // stateless `/responses` item stripping, and `provider.data_collection`). + yield* ctx.session.hook( + "http.request", + (event) => + Effect.promise(async () => { + const dataCollection = + process.env.KILO_DATA_COLLECTION === "deny" + ? ("deny" as const) + : process.env.KILO_DATA_COLLECTION === "allow" + ? ("allow" as const) + : undefined + event.request = await KiloRequests.rewriteRequest(event.request, { dataCollection }) + }), + { providerID: id }, + ) + + // Error contract and SSE stream metadata (`routedModelID`, `x-vercel-id`, and mid-stream SSE errors). + yield* ctx.session.hook( + "http.response", + (event) => + Effect.promise(async () => { + if (event.response.status >= 400) { + event.response = await KiloErrors.rewriteHttpResponse(event.response) + return + } + event.response = KiloStream.transformResponse(event.response, (meta) => { + if (meta.routedModelID || meta.vercelID) { + const prev = sessionMetadata.get(event.sessionID) + if (!prev && sessionMetadata.size >= 500) { + const oldest = sessionMetadata.keys().next().value + if (oldest) sessionMetadata.delete(oldest) + } + sessionMetadata.set(event.sessionID, { + ...(prev?.routedModelID ? { routedModelID: prev.routedModelID } : {}), + ...(prev?.vercelID ? { vercelID: prev.vercelID } : {}), + ...(meta.routedModelID ? { routedModelID: meta.routedModelID } : {}), + ...(meta.vercelID ? { vercelID: meta.vercelID } : {}), + }) + } + }) + }), + { providerID: id }, + ) + yield* ctx.rpc .register(KiloGatewayRpc.Definition, { profile: (_input, context) => @@ -442,6 +540,8 @@ export const KiloGatewayPlugin = define({ } return KiloModels.entries(catalog.models) }), + "session.metadata": (input) => + Effect.sync(() => sessionMetadata.get(input.sessionID) ?? {}), }) .pipe(Effect.orDie) diff --git a/packages/core/test/kilocode/gateway.test.ts b/packages/core/test/kilocode/gateway.test.ts index 8c48112289b0..b7e40a9eeaa9 100644 --- a/packages/core/test/kilocode/gateway.test.ts +++ b/packages/core/test/kilocode/gateway.test.ts @@ -8,10 +8,13 @@ import { KiloGatewayPlugin } from "@opencode/core/kilocode/gateway" import { KiloGatewayRpc } from "@opencode/core/kilocode/gateway-rpc" import { Model } from "@opencode/core/model" import { Plugin } from "@opencode/core/plugin" +import { PluginHooks } from "@opencode/core/plugin/hooks" import { PluginHost } from "@opencode/core/plugin/host" import { ProviderPlugins } from "@opencode/core/plugin/provider" import { Provider } from "@opencode/core/provider" import { Rpc } from "@opencode/core/rpc" +import { Agent } from "@opencode/schema/agent" +import { Session } from "@opencode/schema/session" import { withEnv } from "../fixture/env" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "../plugin/fixture" @@ -28,6 +31,8 @@ const env = { KILO_API_URL: undefined, KILO_AI_GATEWAY_URL: undefined, KILO_ORG_ID: undefined, + KILO_MACHINE_ID: undefined, + KILO_DATA_COLLECTION: undefined, KILOCODE_EDITOR_NAME: undefined, KILOCODE_FEATURE: undefined, KILOCODE_VERSION: undefined, @@ -408,4 +413,137 @@ describe("KiloGatewayPlugin", () => { (backend) => Effect.sync(() => backend[Symbol.dispose]()), ), ) + + it.live( + "injects per-request headers, sanitizes request bodies, rewrites Gateway errors, and records routedModelID", + () => + Effect.acquireUseRelease( + Effect.sync(() => fixture()), + (backend) => + withEnv( + { + ...env, + KILO_API_URL: backend.url, + KILO_API_KEY: "env-token", + KILO_ORG_ID: "selected", + KILO_MACHINE_ID: "mach-test", + KILO_DATA_COLLECTION: "deny", + }, + () => + Effect.gen(function* () { + const host = yield* addPlugin() + const hooks = yield* PluginHooks.Service + const rpc = yield* Rpc.Service + const parent = yield* host.session.create({ title: "parent" }) + const child = yield* host.session.create({ title: "child", parentID: parent.id }) + const modelRef = { + providerID: kilo, + id: Model.ID.make("kilo-auto/frontier"), + } + + const modelEvt = yield* hooks.trigger("session", "model.request", { + sessionID: child.id, + agent: Agent.ID.make("code"), + model: modelRef, + kind: "primary", + headers: {}, + }) + expect(modelEvt.headers).toMatchObject({ + "HTTP-Referer": "https://kilocode.ai", + "X-Title": "Kilo Code", + "X-KILOCODE-ORGANIZATIONID": "selected", + "X-KILOCODE-TASKID": child.id, + "X-KILOCODE-PARENT-TASKID": parent.id, + "X-KILOCODE-MACHINEID": "mach-test", + "x-kilocode-mode": "code", + "User-Agent": "opencode-kilo-provider/unknown", + }) + + const httpReq = yield* hooks.trigger("session", "http.request", { + sessionID: child.id, + agent: Agent.ID.make("code"), + model: modelRef, + kind: "primary", + request: new Request(`${backend.url}/api/gateway/responses`, { + method: "POST", + headers: { "content-type": "application/json", "x-api-key": "env-token" }, + body: JSON.stringify({ + model: "openai/gpt-5.4", + server: backend.url, + organizationID: "selected", + input: [ + { type: "item_reference", id: "item_1" }, + { type: "message", id: "msg_1", role: "user", content: "hello" }, + ], + }), + }), + }) + expect(httpReq.request.headers.get("authorization")).toBe("Bearer env-token") + expect(httpReq.request.headers.get("x-api-key")).toBeNull() + expect(yield* Effect.promise(() => httpReq.request.json())).toEqual({ + model: "openai/gpt-5.4", + input: [{ type: "message", role: "user", content: "hello" }], + provider: { data_collection: "deny" }, + }) + + const errorRes = yield* hooks.trigger("session", "http.response", { + sessionID: child.id, + agent: Agent.ID.make("code"), + model: modelRef, + kind: "primary", + request: httpReq.request, + response: Response.json( + { + error: { + title: "Low balance", + message: "Balance is depleted.", + balance: 0, + buyCreditsUrl: "https://app.kilo.ai/billing", + }, + error_type: "usage_limit_exceeded", + }, + { status: 402 }, + ), + }) + expect(yield* Effect.promise(() => errorRes.response.json())).toMatchObject({ + error: { + code: "usage_limit_exceeded", + message: + "Low balance: Balance is depleted. (Balance: $0.00) Buy credits at https://app.kilo.ai/billing", + }, + }) + + const sseBody = [ + `data: ${JSON.stringify({ + model: "anthropic/claude-sonnet-4.6", + choices: [{ delta: { content: "PONG" }, finish_reason: "stop" }], + usage: { prompt_tokens: 4, completion_tokens: 1, total_tokens: 5 }, + })}`, + "data: [DONE]", + "", + ].join("\n") + const streamRes = yield* hooks.trigger("session", "http.response", { + sessionID: Session.ID.make(child.id), + agent: Agent.ID.make("code"), + model: modelRef, + kind: "primary", + request: httpReq.request, + response: new Response(sseBody, { + status: 200, + headers: { "content-type": "text/event-stream", "x-vercel-id": "iad1::abc" }, + }), + }) + const streamText = yield* Effect.promise(() => streamRes.response.text()) + expect(streamText).toContain('"routedModelID":"anthropic/claude-sonnet-4.6"') + expect( + yield* rpc.call("kilocode.gateway", "session.metadata", { sessionID: child.id }), + ).toEqual({ + routedModelID: "anthropic/claude-sonnet-4.6", + vercelID: "iad1::abc", + }) + }), + ), + (backend) => Effect.sync(() => backend[Symbol.dispose]()), + ), + ) }) diff --git a/packages/kilo-gateway-core/src/errors.ts b/packages/kilo-gateway-core/src/errors.ts new file mode 100644 index 000000000000..ec20c89675d1 --- /dev/null +++ b/packages/kilo-gateway-core/src/errors.ts @@ -0,0 +1,156 @@ +export * as KiloErrors from "./errors.js" + +import { Option, Schema } from "effect" + +const JsonValue = Schema.fromJsonString(Schema.Unknown) +const decodeJson = Schema.decodeUnknownOption(JsonValue) + +export const CODES = { + PAID_MODEL_AUTH_REQUIRED: "PAID_MODEL_AUTH_REQUIRED", + PROMOTION_MODEL_LIMIT_REACHED: "PROMOTION_MODEL_LIMIT_REACHED", + USAGE_LIMIT_EXCEEDED: "usage_limit_exceeded", + INSUFFICIENT_CREDITS: "insufficient_credits", + TOP_UP_IN_PROGRESS: "top_up_in_progress", + MODEL_NOT_ALLOWED: "model_not_allowed", + PROVIDER_NOT_ALLOWED: "provider_not_allowed", + DATA_COLLECTION_REQUIRED: "data_collection_required", + UPGRADE_REQUIRED: "upgrade_required", + RATE_LIMIT_EXCEEDED: "rate_limit_exceeded", + BYOK_KEY_REQUIRED: "byok_key_required", + BYOK_ERROR: "byok_error", +} as const + +export interface ParsedError { + readonly code: string + readonly message: string + readonly retryable: boolean +} + +// Parses both Kilo Gateway error envelopes (from `apps/web/src/app/api/openrouter/[...path]/route.ts` and +// `lib/ai-gateway/llm-proxy-helpers.ts`) plus mid-stream SSE error frames (from v1 `provider/error.ts`): +// 1. Nested `{ error: { code, message, title, balance, buyCreditsUrl }, error_type }` +// 2. Flat `{ error: "", error_type: "", message?: "" }` +// 3. Responses API terminal frame `{ type: "response.failed", response: { error: { code, message } } }` +// 4. Bare `{ code, message }` +export function parse(rawBody: string | unknown, status?: number): ParsedError | undefined { + const value = typeof rawBody === "string" ? Option.getOrUndefined(decodeJson(rawBody)) : rawBody + if (!isRecord(value)) return undefined + + // Unwrap `response.failed` terminal SSE frame (`/responses`). + const unwrapped = + value.type === "response.failed" && isRecord(value.response) && isRecord(value.response.error) + ? { error: value.response.error, error_type: value.error_type } + : value + + const nestedError = isRecord(unwrapped.error) ? unwrapped.error : undefined + const rawCode = + asString(nestedError?.code) ?? + asString(unwrapped.error_type) ?? + asString(unwrapped.code) ?? + asString(nestedError?.type) + const rawMessage = + asString(nestedError?.message) ?? + asString(unwrapped.message) ?? + (typeof unwrapped.error === "string" ? unwrapped.error : undefined) + + if (!rawCode && !rawMessage) return undefined + + const normalizedCode = rawCode ?? (status === 402 ? CODES.USAGE_LIMIT_EXCEEDED : "api_error") + const message = formatMessage(normalizedCode, rawMessage, nestedError, unwrapped) + if (!message) return undefined + + return { + code: normalizedCode, + message, + retryable: isRetryable(normalizedCode, message, status), + } +} + +// Rewrites a non-2xx HTTP `Response` from the Kilo Gateway so that the upstream error classifier +// receives the formatted Kilo message and code while preserving status and headers. +export async function rewriteHttpResponse(response: Response): Promise { + if (response.status < 400) return response + const text = await response.clone().text().catch(() => "") + if (!text) return response + const parsed = parse(text, response.status) + if (!parsed) return response + + const headers = new Headers(response.headers) + headers.set("content-type", "application/json") + headers.delete("content-length") + headers.delete("content-encoding") + const body = JSON.stringify({ + error: { + code: parsed.code, + type: parsed.code, + message: parsed.message, + }, + error_type: parsed.code, + }) + return new Response(body, { + status: response.status, + statusText: response.statusText, + headers, + }) +} + +function formatMessage( + code: string, + rawMessage: string | undefined, + nestedError: Record | undefined, + root: Record, +): string | undefined { + if (code === CODES.PAID_MODEL_AUTH_REQUIRED || code === "paid_model_auth_required") { + return "You need to sign in to use this model. Sign in or create an account to access over 500 models, use credits at cost, or bring your own key." + } + if (code === CODES.PROMOTION_MODEL_LIMIT_REACHED || code === "promotion_limit_reached") { + return "You need to sign up to keep going. Sign up for free to continue and explore 500 other models. Takes 2 minutes, no credit card required. Or come back later." + } + if (code === CODES.USAGE_LIMIT_EXCEEDED || code === CODES.INSUFFICIENT_CREDITS) { + const title = asString(nestedError?.title) ?? asString(root.title) + const buyUrl = asString(nestedError?.buyCreditsUrl) ?? asString(root.buyCreditsUrl) + const balance = nestedError?.balance ?? root.balance + const base = rawMessage ?? title ?? "Insufficient Kilo Gateway credits." + const parts = [ + title && rawMessage && !rawMessage.startsWith(title) ? `${title}: ${rawMessage}` : base, + typeof balance === "number" ? `(Balance: $${balance.toFixed(2)})` : undefined, + buyUrl ? `Buy credits at ${buyUrl}` : undefined, + ].filter(Boolean) + return parts.join(" ") + } + return rawMessage +} + +const RETRYABLE_CODE = + /top_up_in_progress|rate.?limit|too.?many.?requests|rate increased too quickly|exhausted|overload|server|unavailable|timeout/i +const RETRYABLE_TEXT = + /rate increased too quickly|rate.?limit|too.?many.?requests|try again (?:later|in\b)|(?:currently|temporarily) at capacity|temporarily unavailable/i + +function isRetryable(code: string, message: string, status?: number): boolean { + if (code === CODES.TOP_UP_IN_PROGRESS) return true + if ( + code === CODES.PAID_MODEL_AUTH_REQUIRED || + code === "paid_model_auth_required" || + code === CODES.PROMOTION_MODEL_LIMIT_REACHED || + code === "promotion_limit_reached" || + code === CODES.USAGE_LIMIT_EXCEEDED || + code === CODES.INSUFFICIENT_CREDITS || + code === CODES.MODEL_NOT_ALLOWED || + code === CODES.PROVIDER_NOT_ALLOWED || + code === CODES.DATA_COLLECTION_REQUIRED || + code === CODES.UPGRADE_REQUIRED + ) { + return false + } + if (status === 429 || (status !== undefined && status >= 500 && status < 600)) return true + if (RETRYABLE_CODE.test(code)) return true + return RETRYABLE_TEXT.test(message) +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/packages/kilo-gateway-core/src/headers.ts b/packages/kilo-gateway-core/src/headers.ts index 0af03218374d..727fce0a71b4 100644 --- a/packages/kilo-gateway-core/src/headers.ts +++ b/packages/kilo-gateway-core/src/headers.ts @@ -3,20 +3,62 @@ export * as KiloHeaders from "./headers.js" export const HEADER_ORGANIZATION = "X-KILOCODE-ORGANIZATIONID" export const HEADER_EDITOR = "X-KILOCODE-EDITORNAME" export const HEADER_FEATURE = "X-KILOCODE-FEATURE" +export const HEADER_TASK = "X-KILOCODE-TASKID" +export const HEADER_PARENT_TASK = "X-KILOCODE-PARENT-TASKID" +export const HEADER_PROJECT = "X-KILOCODE-PROJECTID" +export const HEADER_MACHINE = "X-KILOCODE-MACHINEID" +export const HEADER_TESTER = "X-KILOCODE-TESTER" +export const HEADER_MODE = "x-kilocode-mode" +export const USER_AGENT_PREFIX = "opencode-kilo-provider" +export const TESTER_SUPPRESS_VALUE = "SUPPRESS" export interface Options { readonly version: string readonly organizationID?: string | null + readonly testerWarningsDisabledUntil?: number +} + +export interface RequestOptions extends Options { + readonly sessionID?: string + readonly parentSessionID?: string + readonly projectID?: string + readonly machineID?: string + readonly mode?: string } // Provider-level attribution headers from v1 (`packages/opencode/src/kilocode/const.ts` and -// `packages/kilo-gateway/src/headers.ts`). Per-request task/project/machine headers belong to PR 3. +// `packages/kilo-gateway/src/headers.ts`). export function provider(options: Options, env: Record = process.env): Record { + const version = env.KILOCODE_VERSION || options.version + const testerUntil = + options.testerWarningsDisabledUntil ?? + (env.KILO_TESTER_WARNINGS_DISABLED_UNTIL ? Number(env.KILO_TESTER_WARNINGS_DISABLED_UNTIL) : undefined) return { "HTTP-Referer": "https://kilocode.ai", "X-Title": "Kilo Code", - [HEADER_EDITOR]: env.KILOCODE_EDITOR_NAME || `Kilo CLI ${env.KILOCODE_VERSION || options.version}`, + [HEADER_EDITOR]: env.KILOCODE_EDITOR_NAME || `Kilo CLI ${version}`, [HEADER_FEATURE]: env.KILOCODE_FEATURE || "cli", ...(options.organizationID ? { [HEADER_ORGANIZATION]: options.organizationID } : {}), + ...(env.KILO_TESTER === TESTER_SUPPRESS_VALUE || (testerUntil && testerUntil > Date.now()) + ? { [HEADER_TESTER]: TESTER_SUPPRESS_VALUE } + : {}), + } +} + +// Per-request attribution headers from v1 (`packages/opencode/src/session/llm/request.ts` and +// `packages/kilo-gateway/src/headers.ts`). +export function request( + options: RequestOptions, + env: Record = process.env, +): Record { + const version = env.KILOCODE_VERSION || options.version + return { + ...provider(options, env), + "User-Agent": version ? `${USER_AGENT_PREFIX}/${version}` : USER_AGENT_PREFIX, + ...(options.sessionID ? { [HEADER_TASK]: options.sessionID } : {}), + ...(options.parentSessionID ? { [HEADER_PARENT_TASK]: options.parentSessionID } : {}), + ...(options.projectID ? { [HEADER_PROJECT]: options.projectID } : {}), + ...(options.machineID ? { [HEADER_MACHINE]: options.machineID } : {}), + ...(options.mode ? { [HEADER_MODE]: options.mode.toLowerCase() } : {}), } } diff --git a/packages/kilo-gateway-core/src/index.ts b/packages/kilo-gateway-core/src/index.ts index 46f64f0d7bb1..44e9bd3eabb2 100644 --- a/packages/kilo-gateway-core/src/index.ts +++ b/packages/kilo-gateway-core/src/index.ts @@ -1,7 +1,12 @@ export * as KiloAccount from "./account.js" export * as KiloDefaults from "./defaults.js" export * as KiloDevice from "./device.js" +export * as KiloErrors from "./errors.js" export * as KiloHeaders from "./headers.js" +export * as KiloMachine from "./machine.js" export * as KiloModels from "./models.js" export * as KiloProfile from "./profile.js" +export * as KiloProject from "./project.js" +export * as KiloRequests from "./requests.js" export * as KiloServer from "./server.js" +export * as KiloStream from "./stream.js" diff --git a/packages/kilo-gateway-core/src/machine.ts b/packages/kilo-gateway-core/src/machine.ts new file mode 100644 index 000000000000..db7742004949 --- /dev/null +++ b/packages/kilo-gateway-core/src/machine.ts @@ -0,0 +1,28 @@ +export * as KiloMachine from "./machine.js" + +import { randomUUID } from "node:crypto" +import { mkdir, readFile, writeFile } from "node:fs/promises" +import path from "node:path" +import { Effect } from "effect" + +// Resolves or creates a stable anonymous machine ID like v1 (`packages/kilo-telemetry/src/identity.ts`): +// `KILO_MACHINE_ID` env override > `/telemetry-id` file > generated UUID written to `/telemetry-id`. +export function get( + dataDir?: string, + env: Record = process.env, +): Effect.Effect { + return Effect.promise(async () => { + const fromEnv = env.KILO_MACHINE_ID?.trim() + if (fromEnv) return fromEnv + if (!dataDir) return undefined + const filePath = path.join(dataDir, "telemetry-id") + const existing = await readFile(filePath, "utf8") + .then((value) => value.trim()) + .catch(() => "") + if (existing) return existing + const created = randomUUID() + await mkdir(dataDir, { recursive: true }).catch(() => undefined) + await writeFile(filePath, created, "utf8").catch(() => undefined) + return created + }) +} diff --git a/packages/kilo-gateway-core/src/project.ts b/packages/kilo-gateway-core/src/project.ts new file mode 100644 index 000000000000..7e63580bf435 --- /dev/null +++ b/packages/kilo-gateway-core/src/project.ts @@ -0,0 +1,64 @@ +export * as KiloProject from "./project.js" + +import { execFile } from "node:child_process" +import { readFile } from "node:fs/promises" +import path from "node:path" +import { promisify } from "node:util" +import { Effect, Option, Schema } from "effect" + +const execFileAsync = promisify(execFile) + +const ConfigFile = Schema.fromJsonString( + Schema.Struct({ + project: Schema.optionalKey( + Schema.Struct({ + id: Schema.optionalKey(Schema.String), + }), + ), + }), +) +const decodeConfigFile = Schema.decodeUnknownOption(ConfigFile) + +// Normalizes a project identifier like v1 `normalizeProjectId` (`packages/opencode/src/kilocode/project-id.ts`): +// extracts the repository name from HTTP(S)/SSH/SCP git URLs, strips `.git`, and truncates to the last 100 chars. +export function normalize(input: string): string { + const trimmed = input.trim() + if (URL.canParse(trimmed)) { + const url = new URL(trimmed) + const pathname = url.pathname.replace(/\.git$/i, "") + const parts = pathname.split("/").filter(Boolean) + const repo = parts.at(-1) + return (repo || trimmed).slice(-100) + } + const scpMatch = /^git@[^:]+:(.+)/i.exec(trimmed) + if (scpMatch?.[1]) { + const pathname = scpMatch[1].replace(/\.git$/i, "") + const parts = pathname.split("/").filter(Boolean) + const repo = parts.at(-1) + return (repo || trimmed).slice(-100) + } + return trimmed.slice(-100) +} + +// Resolves the project ID for a directory with v1 priority: +// 1. `.kilo/config.json` (`project.id`) +// 2. `.kilocode/config.json` (`project.id`) +// 3. `git config --get remote.origin.url` +export function resolve(directory: string): Effect.Effect { + return Effect.promise(async () => { + for (const folder of [".kilo", ".kilocode"]) { + const text = await readFile(path.join(directory, folder, "config.json"), "utf8").catch(() => undefined) + if (!text) continue + const parsed = decodeConfigFile(text) + const id = Option.isSome(parsed) ? parsed.value.project?.id?.trim() : undefined + if (id) return normalize(id) + } + const gitUrl = await execFileAsync("git", ["config", "--get", "remote.origin.url"], { + cwd: directory, + timeout: 2_000, + }) + .then((result) => result.stdout.trim()) + .catch(() => "") + return gitUrl ? normalize(gitUrl) : undefined + }) +} diff --git a/packages/kilo-gateway-core/src/requests.ts b/packages/kilo-gateway-core/src/requests.ts new file mode 100644 index 000000000000..78ba93fca900 --- /dev/null +++ b/packages/kilo-gateway-core/src/requests.ts @@ -0,0 +1,129 @@ +export * as KiloRequests from "./requests.js" + +import { Option, Schema } from "effect" + +const JsonRecord = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)) +const decodeJsonRecord = Schema.decodeUnknownOption(JsonRecord) + +// Reserved account and credential metadata keys that Core merges into model settings before building a request. +// These must never serialize into the outgoing JSON request body sent to the Kilo Gateway. +export const ACCOUNT_BODY_KEYS = new Set([ + "access", + "apiKey", + "authToken", + "email", + "hasPersonalAccount", + "key", + "name", + "organizationID", + "organizationName", + "organizations", + "refresh", + "selectedOrganizationId", + "server", + "token", + "user", +]) + +export interface TransformOptions { + readonly dataCollection?: "allow" | "deny" +} + +// Transforms a JSON request body for the Kilo Gateway (ported from v1 `packages/kilo-gateway/src/responses.ts` +// and the v2 request body sanitizer): +// 1. Strips leaked credential/account metadata keys from the top-level JSON object. +// 2. On `/responses` when `store !== true`, strips `item_reference` entries and item `id` fields from `input[]`. +// 3. Injects `provider.data_collection` when specified (`"allow"` or `"deny"`). +export function transformBody( + urlOrPath: string | URL, + body: string | undefined, + options?: TransformOptions, +): string | undefined { + if (typeof body !== "string" || body.length === 0) return body + const parsed = decodeJsonRecord(body) + if (Option.isNone(parsed)) return body + const data = parsed.value + + let changed = false + const next: Record = {} + for (const [key, value] of Object.entries(data)) { + if (ACCOUNT_BODY_KEYS.has(key)) { + changed = true + continue + } + next[key] = value + } + + if (isResponsesEndpoint(urlOrPath) && next.store !== true && Array.isArray(next.input)) { + const stripped = stripResponsesInput(next.input) + if (stripped.changed) { + next.input = stripped.kept + changed = true + } + } + + if (options?.dataCollection) { + const existingProvider = isRecord(next.provider) ? next.provider : {} + if (existingProvider.data_collection !== options.dataCollection) { + next.provider = { ...existingProvider, data_collection: options.dataCollection } + changed = true + } + } + + return changed ? JSON.stringify(next) : body +} + +// Rewrites an outgoing Web `Request` for the Kilo Gateway: +// - Promotes `x-api-key` to `Authorization: Bearer ` (needed when an Anthropic dialect client sends +// a key credential to `/api/gateway/messages`). +// - Applies `transformBody` to JSON bodies. +export async function rewriteRequest(request: Request, options?: TransformOptions): Promise { + const headers = new Headers(request.headers) + let headersChanged = false + const apiKey = headers.get("x-api-key")?.trim() + if (apiKey) { + if (!headers.has("authorization")) { + headers.set("authorization", `Bearer ${apiKey}`) + } + headers.delete("x-api-key") + headersChanged = true + } + + if (request.method === "GET" || request.method === "HEAD" || !request.body) { + return headersChanged ? new Request(request, { headers }) : request + } + + const rawBody = await request.clone().text().catch(() => undefined) + const transformed = transformBody(request.url, rawBody, options) + if (transformed === rawBody && !headersChanged) return request + + headers.delete("content-length") + return new Request(request, { + headers, + body: transformed ?? rawBody, + duplex: "half", + } as RequestInit) +} + +function isResponsesEndpoint(urlOrPath: string | URL): boolean { + const raw = urlOrPath instanceof URL ? urlOrPath.pathname : urlOrPath + const pathname = URL.canParse(raw) ? new URL(raw).pathname : (raw.split(/[?#]/, 1)[0] ?? "") + return pathname.endsWith("/responses") +} + +function stripResponsesInput(input: readonly unknown[]) { + const kept = input.flatMap((item) => { + if (!isRecord(item)) return [item] + if (item.type === "item_reference") return [] + if (!("id" in item)) return [item] + const copy = { ...item } + delete copy.id + return [copy] + }) + const changed = kept.length !== input.length || kept.some((item, index) => item !== input[index]) + return { kept, changed } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/packages/kilo-gateway-core/src/stream.ts b/packages/kilo-gateway-core/src/stream.ts new file mode 100644 index 000000000000..7d9305c2efe9 --- /dev/null +++ b/packages/kilo-gateway-core/src/stream.ts @@ -0,0 +1,207 @@ +export * as KiloStream from "./stream.js" + +import { Option, Schema } from "effect" +import { KiloErrors } from "./errors.js" + +const JsonValue = Schema.fromJsonString(Schema.Unknown) +const decodeJson = Schema.decodeUnknownOption(JsonValue) +const MODEL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/@+-]*$/ + +export interface StreamMetadata { + readonly routedModelID?: string + readonly gateway?: Record + readonly vercelID?: string +} + +// Inspects a single decoded SSE JSON payload for Kilo Gateway routing metadata +// (ported from v1 `packages/kilo-gateway/src/gateway-metadata.ts` and `routed.ts`). +export function extractMetadata(payload: unknown): { + readonly routedModelID?: string + readonly gateway?: Record +} { + if (!isRecord(payload)) return {} + const response = isRecord(payload.response) ? payload.response : undefined + const message = isRecord(payload.message) ? payload.message : undefined + const firstChoice = Array.isArray(payload.choices) && isRecord(payload.choices[0]) ? payload.choices[0] : undefined + const delta = isRecord(firstChoice?.delta) ? firstChoice.delta : undefined + + const gateway = + readGateway(payload.provider_metadata) ?? + readGateway(response?.provider_metadata) ?? + readGateway(delta?.provider_metadata) + + const routedFromGateway = gateway ? extractRoutedSlug(gateway) : undefined + const directModel = + validModelID(routedFromGateway) ?? + validModelID(response?.model) ?? + validModelID(message?.model) ?? + validModelID(payload.model) + + return { + ...(directModel ? { routedModelID: directModel } : {}), + ...(gateway ? { gateway } : {}), + } +} + +// Normalizes a single SSE `data: ` payload so that: +// 1. Mid-stream error frames (`response.failed`, envelope-less `{ error }`, bare `{ code, message }`) are +// rewritten into the standard `{ type: "error", error: { code, type, message } }` shape with Kilo's +// formatted error message. +// 2. Routing metadata (`routedModelID`, `gateway`) is reported to `onMetadata` and stamped onto `usage` +// when present so `OpenAIChat` / `OpenRouter` `Usage.providerMetadata` retains `routedModelID`. +export function transformSseData( + rawData: string, + onMetadata?: (meta: { readonly routedModelID?: string; readonly gateway?: Record }) => void, + currentRoutedModelID?: string, +): string { + const trimmed = rawData.trim() + if (!trimmed || trimmed === "[DONE]") return rawData + const decoded = decodeJson(trimmed) + if (Option.isNone(decoded) || !isRecord(decoded.value)) return rawData + const payload = decoded.value + + const meta = extractMetadata(payload) + if (onMetadata && (meta.routedModelID || meta.gateway)) { + onMetadata(meta) + } + const effectiveRoutedModelID = meta.routedModelID ?? currentRoutedModelID + + // Only rewrite frames that represent an error (`type === "error"`, `type === "response.failed"`, + // top-level `error` field, or bare `{ code, message }` without normal stream fields). + const isErrorFrame = + payload.type === "error" || + payload.type === "response.failed" || + payload.error !== undefined || + (payload.type === undefined && + payload.choices === undefined && + payload.id === undefined && + payload.code !== undefined && + typeof payload.message === "string") + if (isErrorFrame) { + const parsed = KiloErrors.parse(payload) + if (!parsed) return rawData + return JSON.stringify({ + ...payload, + type: "error", + error: { + ...(isRecord(payload.error) ? payload.error : {}), + code: parsed.code, + type: parsed.code, + message: parsed.message, + }, + }) + } + + if (effectiveRoutedModelID && isRecord(payload.usage) && payload.usage.routedModelID === undefined) { + return JSON.stringify({ + ...payload, + usage: { + ...payload.usage, + routedModelID: effectiveRoutedModelID, + }, + }) + } + + return rawData +} + +// Wraps an HTTP 200 `text/event-stream` `Response` to extract `routedModelID` / `x-vercel-id` and +// normalize mid-stream SSE error frames on the fly. +export function transformResponse( + response: Response, + onComplete?: (metadata: StreamMetadata) => void, +): Response { + const contentType = response.headers.get("content-type") ?? "" + const vercelID = response.headers.get("x-vercel-id")?.trim() || undefined + if (!response.body || !contentType.includes("text/event-stream")) { + if (vercelID && onComplete) onComplete({ vercelID }) + return response + } + + const decoder = new TextDecoder() + const encoder = new TextEncoder() + let buffer = "" + let routedModelID: string | undefined + let gateway: Record | undefined + + const recordMeta = (meta: { readonly routedModelID?: string; readonly gateway?: Record }) => { + if (meta.routedModelID) routedModelID = meta.routedModelID + if (meta.gateway) gateway = { ...gateway, ...meta.gateway } + } + + const processLine = (line: string): string => { + const cr = line.endsWith("\r") ? "\r" : "" + const stripped = cr ? line.slice(0, -1) : line + if (!stripped.startsWith("data:")) return line + const content = stripped.slice("data:".length).replace(/^ /, "") + const next = transformSseData(content, recordMeta, routedModelID) + return next === content ? line : `data: ${next}${cr}` + } + + const stream = response.body.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + buffer += decoder.decode(chunk, { stream: true }) + const lines = buffer.split("\n") + buffer = lines.pop() ?? "" + if (lines.length > 0) { + controller.enqueue(encoder.encode(`${lines.map(processLine).join("\n")}\n`)) + } + }, + flush(controller) { + buffer += decoder.decode() + if (buffer.length > 0) { + controller.enqueue(encoder.encode(processLine(buffer))) + buffer = "" + } + if (onComplete) { + onComplete({ + ...(routedModelID ? { routedModelID } : {}), + ...(gateway ? { gateway } : {}), + ...(vercelID ? { vercelID } : {}), + }) + } + }, + }), + ) + + const headers = new Headers(response.headers) + headers.delete("content-length") + headers.delete("content-encoding") + return new Response(stream, { + status: response.status, + statusText: response.statusText, + headers, + }) +} + +function readGateway(providerMetadata: unknown): Record | undefined { + if (!isRecord(providerMetadata)) return undefined + return isRecord(providerMetadata.gateway) ? providerMetadata.gateway : undefined +} + +function extractRoutedSlug(gateway: Record): string | undefined { + const routing = isRecord(gateway.routing) ? gateway.routing : undefined + if (!routing) return undefined + const attempts = Array.isArray(routing.modelAttempts) ? routing.modelAttempts : [] + for (let i = attempts.length - 1; i >= 0; i--) { + const item = attempts[i] + if (isRecord(item) && item.success === true && typeof item.canonicalSlug === "string" && item.canonicalSlug.trim()) { + return item.canonicalSlug.trim() + } + } + return typeof routing.canonicalSlug === "string" && routing.canonicalSlug.trim() + ? routing.canonicalSlug.trim() + : undefined +} + +function validModelID(value: unknown): string | undefined { + if (typeof value !== "string") return undefined + const trimmed = value.trim() + if (!trimmed || trimmed.length > 256 || !MODEL_ID_PATTERN.test(trimmed)) return undefined + return trimmed +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/packages/kilo-gateway-core/test/requests-errors.test.ts b/packages/kilo-gateway-core/test/requests-errors.test.ts new file mode 100644 index 000000000000..c94d34e6d3ab --- /dev/null +++ b/packages/kilo-gateway-core/test/requests-errors.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, test } from "bun:test" +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { Effect } from "effect" +import { KiloErrors, KiloHeaders, KiloMachine, KiloProject, KiloRequests, KiloStream } from "../src/index.js" + +describe("KiloHeaders.request, KiloProject, and KiloMachine", () => { + test("builds full per-request Kilo headers including task, parent task, project, machine, mode, tester, and User-Agent", () => { + const headers = KiloHeaders.request( + { + version: "2.0.27", + organizationID: "org-123", + sessionID: "ses_1", + parentSessionID: "ses_0", + projectID: "kilocode", + machineID: "mach_1", + mode: "code", + testerWarningsDisabledUntil: Date.now() + 60_000, + }, + {}, + ) + expect(headers).toEqual({ + "HTTP-Referer": "https://kilocode.ai", + "X-Title": "Kilo Code", + "X-KILOCODE-EDITORNAME": "Kilo CLI 2.0.27", + "X-KILOCODE-FEATURE": "cli", + "X-KILOCODE-ORGANIZATIONID": "org-123", + "X-KILOCODE-TESTER": "SUPPRESS", + "User-Agent": "opencode-kilo-provider/2.0.27", + "X-KILOCODE-TASKID": "ses_1", + "X-KILOCODE-PARENT-TASKID": "ses_0", + "X-KILOCODE-PROJECTID": "kilocode", + "X-KILOCODE-MACHINEID": "mach_1", + "x-kilocode-mode": "code", + }) + }) + + test("normalizes git URLs and reads project ID from .kilo/config.json before git remote", async () => { + expect(KiloProject.normalize("https://github.com/Kilo-Org/kilocode.git")).toBe("kilocode") + expect(KiloProject.normalize("git@github.com:Kilo-Org/kilocode.git")).toBe("kilocode") + expect(KiloProject.normalize("a".repeat(120))).toHaveLength(100) + + const dir = await mkdtemp(path.join(os.tmpdir(), "kilo-proj-")) + try { + await mkdir(path.join(dir, ".kilo"), { recursive: true }) + await writeFile( + path.join(dir, ".kilo", "config.json"), + JSON.stringify({ project: { id: "https://github.com/Kilo-Org/custom-repo.git" } }), + ) + expect(await Effect.runPromise(KiloProject.resolve(dir))).toBe("custom-repo") + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + test("reads KILO_MACHINE_ID or persists telemetry-id in dataDir", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "kilo-mach-")) + try { + expect(await Effect.runPromise(KiloMachine.get(dir, { KILO_MACHINE_ID: "env-machine" }))).toBe("env-machine") + const first = await Effect.runPromise(KiloMachine.get(dir, {})) + expect(first).toBeTruthy() + const second = await Effect.runPromise(KiloMachine.get(dir, {})) + expect(second).toBe(first) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) +}) + +describe("KiloRequests", () => { + test("promotes x-api-key to Bearer, strips credential metadata keys, strips stateless /responses ids, and injects data_collection", async () => { + const req = new Request("https://api.kilo.ai/api/gateway/responses", { + method: "POST", + headers: { + "content-type": "application/json", + "x-api-key": "kilo-secret", + }, + body: JSON.stringify({ + model: "openai/gpt-5.4", + server: "https://api.kilo.ai", + organizationID: "org-1", + email: "user@example.test", + input: [ + { type: "item_reference", id: "ref_1" }, + { type: "message", id: "msg_1", role: "user", content: "hi" }, + ], + }), + }) + const rewritten = await KiloRequests.rewriteRequest(req, { dataCollection: "deny" }) + expect(rewritten.headers.get("authorization")).toBe("Bearer kilo-secret") + expect(rewritten.headers.get("x-api-key")).toBeNull() + const body = await rewritten.json() + expect(body).toEqual({ + model: "openai/gpt-5.4", + input: [{ type: "message", role: "user", content: "hi" }], + provider: { data_collection: "deny" }, + }) + }) +}) + +describe("KiloErrors", () => { + test("formats PAID_MODEL_AUTH_REQUIRED, PROMOTION_MODEL_LIMIT_REACHED, and 402 usage_limit_exceeded with balance and buyCreditsUrl", async () => { + const authRes = await KiloErrors.rewriteHttpResponse( + Response.json( + { error: { code: "PAID_MODEL_AUTH_REQUIRED", message: "Auth required" }, error_type: "paid_model_auth_required" }, + { status: 401 }, + ), + ) + expect(authRes.status).toBe(401) + expect(await authRes.json()).toMatchObject({ + error: { + code: "PAID_MODEL_AUTH_REQUIRED", + message: + "You need to sign in to use this model. Sign in or create an account to access over 500 models, use credits at cost, or bring your own key.", + }, + }) + + const promoRes = await KiloErrors.rewriteHttpResponse( + Response.json( + { error: { code: "PROMOTION_MODEL_LIMIT_REACHED", message: "Limit reached" }, error_type: "promotion_limit_reached" }, + { status: 401 }, + ), + ) + expect(await promoRes.json()).toMatchObject({ + error: { + code: "PROMOTION_MODEL_LIMIT_REACHED", + message: + "You need to sign up to keep going. Sign up for free to continue and explore 500 other models. Takes 2 minutes, no credit card required. Or come back later.", + }, + }) + + const creditsRes = await KiloErrors.rewriteHttpResponse( + Response.json( + { + error: { + title: "Credits exhausted", + message: "Your organization balance is depleted.", + balance: 0, + buyCreditsUrl: "https://app.kilo.ai/billing", + }, + error_type: "usage_limit_exceeded", + }, + { status: 402 }, + ), + ) + expect(creditsRes.status).toBe(402) + expect(await creditsRes.json()).toMatchObject({ + error: { + code: "usage_limit_exceeded", + message: + "Credits exhausted: Your organization balance is depleted. (Balance: $0.00) Buy credits at https://app.kilo.ai/billing", + }, + }) + }) + + test("classifies top_up_in_progress as retryable and policy/allow-list errors as non-retryable", () => { + expect( + KiloErrors.parse( + JSON.stringify({ + error: "Auto top-up in progress", + error_type: "top_up_in_progress", + message: "Auto top-up is in progress, please retry shortly.", + }), + 503, + ), + ).toEqual({ + code: "top_up_in_progress", + message: "Auto top-up is in progress, please retry shortly.", + retryable: true, + }) + + for (const [errorType, status, msg] of [ + ["model_not_allowed", 404, "Model is blocked by organization policy"], + ["provider_not_allowed", 403, "Provider is blocked by organization policy"], + ["data_collection_required", 400, "No zero-data-retention endpoint available for this model"], + ["upgrade_required", 426, "Please upgrade your Kilo CLI to continue"], + ] as const) { + expect(KiloErrors.parse(JSON.stringify({ error: msg, error_type: errorType }), status)).toEqual({ + code: errorType, + message: msg, + retryable: false, + }) + } + }) +}) + +describe("KiloStream", () => { + test("extracts routedModelID from gateway routing metadata and stamps usage on SSE streams", async () => { + let completed: KiloStream.StreamMetadata | undefined + const sse = [ + `data: ${JSON.stringify({ + type: "message_start", + provider_metadata: { + gateway: { + routing: { + modelAttempts: [ + { canonicalSlug: "openai/gpt-5.4", success: false }, + { canonicalSlug: "anthropic/claude-sonnet-4.6", success: true }, + ], + }, + }, + }, + message: { model: "kilo-auto/frontier" }, + })}`, + `data: ${JSON.stringify({ + choices: [{ delta: { content: "Hello" }, finish_reason: "stop" }], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + })}`, + "data: [DONE]", + "", + ].join("\n") + + const response = KiloStream.transformResponse( + new Response(sse, { + status: 200, + headers: { "content-type": "text/event-stream", "x-vercel-id": "iad1::12345" }, + }), + (meta) => { + completed = meta + }, + ) + const text = await response.text() + expect(text).toContain('"routedModelID":"anthropic/claude-sonnet-4.6"') + expect(completed).toEqual({ + routedModelID: "anthropic/claude-sonnet-4.6", + gateway: { + routing: { + modelAttempts: [ + { canonicalSlug: "openai/gpt-5.4", success: false }, + { canonicalSlug: "anthropic/claude-sonnet-4.6", success: true }, + ], + }, + }, + vercelID: "iad1::12345", + }) + }) + + test("normalizes mid-stream response.failed and bare error SSE frames", () => { + const failedFrame = KiloStream.transformSseData( + JSON.stringify({ + type: "response.failed", + response: { + error: { + code: "PAID_MODEL_AUTH_REQUIRED", + message: "Raw auth error", + }, + }, + }), + ) + expect(JSON.parse(failedFrame)).toMatchObject({ + type: "error", + error: { + code: "PAID_MODEL_AUTH_REQUIRED", + message: + "You need to sign in to use this model. Sign in or create an account to access over 500 models, use credits at cost, or bring your own key.", + }, + }) + }) +})