diff --git a/packages/core/src/plugin/provider.ts b/packages/core/src/plugin/provider.ts index 7c17bcc4c7..d92d8537ad 100644 --- a/packages/core/src/plugin/provider.ts +++ b/packages/core/src/plugin/provider.ts @@ -15,6 +15,7 @@ import { GooglePlugin } from "./provider/google" import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "./provider/google-vertex" import { GroqPlugin } from "./provider/groq" import { KiloPlugin } from "./provider/kilo" +import { KimiForCodingPlugin } from "./provider/kimi-for-coding" import { LLMGatewayPlugin } from "./provider/llmgateway" import { MistralPlugin } from "./provider/mistral" import { NvidiaPlugin } from "./provider/nvidia" @@ -35,6 +36,7 @@ import type { PluginInternal } from "./internal" export const ProviderPlugins: PluginInternal.InternalPlugin[] = [ AlibabaPlugin, AmazonBedrockPlugin, + KimiForCodingPlugin, AnthropicPlugin, AzureCognitiveServicesPlugin, AzurePlugin, diff --git a/packages/core/src/plugin/provider/kimi-for-coding.ts b/packages/core/src/plugin/provider/kimi-for-coding.ts new file mode 100644 index 0000000000..f2bb765868 --- /dev/null +++ b/packages/core/src/plugin/provider/kimi-for-coding.ts @@ -0,0 +1,254 @@ +import { arch, hostname, platform, release } from "node:os" +import path from "node:path" +import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration" +import { define } from "@opencode-ai/plugin/v2/effect/plugin" +import { Clock, Effect, Option, Schema, Semaphore, Stream } from "effect" +import { Global } from "@opencode-ai/util/global" +import { App } from "../../app" +import { Credential } from "../../credential" +import { EventV2 } from "../../event" +import { Integration } from "../../integration" +import { ProviderV2 } from "../../provider" +import type { PluginInternal } from "../internal" + +const clientID = "17e5f671-d194-4dfb-9706-5516cb48c098" +const issuer = "https://auth.kimi.com" +const deviceGrant = "urn:ietf:params:oauth:grant-type:device_code" +const pollingSafetyMargin = 3000 +const methodID = Integration.MethodID.make("device") +const providerID = ProviderV2.ID.make("kimi-for-coding") + +const Device = Schema.Struct({ + device_code: Schema.String, + user_code: Schema.String, + verification_uri: Schema.optional(Schema.String), + verification_uri_complete: Schema.String, + expires_in: Schema.optional(Schema.Number), + interval: Schema.optional(Schema.Number), +}) + +const Token = Schema.Struct({ + access_token: Schema.String, + refresh_token: Schema.String, + expires_in: Schema.Number, +}) +type Token = typeof Token.Type + +const TokenError = Schema.Struct({ + error: Schema.optional(Schema.String), + error_description: Schema.optional(Schema.String), +}) +const decodeTokenError = Schema.decodeUnknownOption(Schema.fromJsonString(TokenError)) + +const oauth = (headers: Record) => + ({ + integrationID: Integration.ID.make("kimi-for-coding"), + method: { + id: methodID, + type: "oauth", + label: "Kimi Code subscription (OAuth)", + }, + authorize: () => + request( + `${issuer}/api/oauth/device_authorization`, + { + method: "POST", + headers: formHeaders(headers), + body: new URLSearchParams({ client_id: clientID }).toString(), + }, + Device, + ).pipe( + Effect.flatMap((device) => + Clock.currentTimeMillis.pipe( + Effect.map((created) => { + const lifetime = positiveSeconds(device.expires_in, 0) + return { + mode: "auto" as const, + url: device.verification_uri_complete, + instructions: device.verification_uri + ? `Open ${device.verification_uri} on any device and enter code: ${device.user_code}` + : `Enter code: ${device.user_code}`, + ...(lifetime ? { expiresAt: created + lifetime * 1000 } : {}), + callback: poll(device, headers).pipe(Effect.map(credential)), + } + }), + ), + ), + ), + refresh: (value) => refresh(Credential.OAuth.make({ ...value, methodID }), headers), + }) satisfies IntegrationOAuthMethodRegistration + +export const KimiForCodingPlugin = define({ + id: "opencode.provider.kimi-for-coding", + effect: Effect.fn(function* (ctx) { + const events = yield* EventV2.Service + const loading = Semaphore.makeUnsafe(1) + const headers = identityHeaders(ctx.app, yield* deviceID()) + let connected = false + + const load = Effect.fn("KimiForCodingPlugin.load")(function* () { + const connection = yield* ctx.integration.connection.active("kimi-for-coding") + const value = connection + ? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined))) + : undefined + connected = value?.type === "oauth" && value.methodID === methodID + }) + + yield* ctx.integration.transform((draft) => { + draft.method.update(oauth(headers)) + }) + yield* load() + yield* ctx.catalog.transform((evt) => { + if (!connected) return + const item = evt.provider.get(providerID) + if (!item) return + evt.provider.update(providerID, (provider) => { + // Kimi's OAuth token is a bearer credential on the managed OpenAI-compatible API. + // The API-key connection remains on the catalog's Anthropic transport. + provider.package = ProviderV2.aisdk("@ai-sdk/openai-compatible") + provider.headers = ProviderV2.mergeHeaders(provider.headers, headers) + }) + }) + + const reload = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload()))) + yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe( + Stream.filter((event) => event.data.integrationID === Integration.ID.make("kimi-for-coding")), + Stream.runForEach(reload), + Effect.forkScoped({ startImmediately: true }), + ) + }), +} satisfies PluginInternal.InternalPlugin) + +function poll(device: typeof Device.Type, headers: Record): Effect.Effect { + return Effect.gen(function* () { + const started = yield* Clock.currentTimeMillis + const expires = started + positiveSeconds(device.expires_in, 300) * 1000 + const loop = (interval: number): Effect.Effect => + Effect.gen(function* () { + if ((yield* Clock.currentTimeMillis) >= expires) { + return yield* Effect.fail(new Error("Kimi Code device authorization timed out")) + } + const response = yield* send(`${issuer}/api/oauth/token`, { + method: "POST", + headers: formHeaders(headers), + body: new URLSearchParams({ + client_id: clientID, + device_code: device.device_code, + grant_type: deviceGrant, + }).toString(), + }) + if (response.ok) return yield* decode(response, Token) + const error = yield* Effect.promise(() => response.text()).pipe( + Effect.map((body) => Option.getOrUndefined(decodeTokenError(body))), + Effect.catch(() => Effect.succeed(undefined)), + ) + if (error?.error === "authorization_pending") { + return yield* Effect.sleep(interval + pollingSafetyMargin).pipe(Effect.andThen(loop(interval))) + } + if (error?.error === "slow_down") { + const next = interval + 5000 + return yield* Effect.sleep(next + pollingSafetyMargin).pipe(Effect.andThen(loop(next))) + } + if (error?.error === "expired_token") { + return yield* Effect.fail(new Error("Kimi Code device code expired - please re-run login")) + } + if (error?.error === "access_denied") { + return yield* Effect.fail(new Error("Kimi Code device authorization was denied")) + } + const detail = error?.error_description ?? error?.error + return yield* Effect.fail( + new Error(`Kimi Code token exchange failed (${response.status})${detail ? `: ${detail}` : ""}`), + ) + }) + return yield* loop(Math.max(positiveSeconds(device.interval, 5) * 1000, 1000)) + }) +} + +function refresh(value: Credential.OAuth, headers: Record) { + return request( + `${issuer}/api/oauth/token`, + { + method: "POST", + headers: formHeaders(headers), + body: new URLSearchParams({ + client_id: clientID, + grant_type: "refresh_token", + refresh_token: value.refresh, + }).toString(), + }, + Token, + ).pipe(Effect.map((token) => credential(token, value.metadata))) +} + +function credential(token: Token, metadata?: Readonly>) { + return Credential.OAuth.make({ + type: "oauth", + methodID, + access: token.access_token, + refresh: token.refresh_token, + expires: Date.now() + positiveSeconds(token.expires_in, 3600) * 1000, + metadata, + }) +} + +function request>(url: string, init: RequestInit, schema: S) { + return send(url, init).pipe( + Effect.flatMap((response) => { + if (response.ok) return decode(response, schema) + return Effect.promise(() => response.text()).pipe( + Effect.flatMap((detail) => + Effect.fail(new Error(`Kimi Code request failed (${response.status})${detail ? `: ${detail}` : ""}`)), + ), + ) + }), + ) +} + +function send(url: string, init: RequestInit) { + return Effect.tryPromise({ + try: (signal) => fetch(url, { ...init, signal }), + catch: (cause) => cause, + }) +} + +function decode>(response: Response, schema: S) { + return Effect.promise(() => response.json()).pipe(Effect.map(Schema.decodeUnknownSync(schema))) +} + +function identityHeaders(app: App.Info, id: string) { + return { + "User-Agent": `opencode/${app.version}`, + "X-Msh-Platform": "kimi_code_cli", + "X-Msh-Version": app.version, + "X-Msh-Device-Name": ascii(hostname()), + "X-Msh-Device-Model": ascii(`${platform()} ${release()} ${arch()}`), + "X-Msh-Os-Version": ascii(release()), + "X-Msh-Device-Id": id, + } +} + +function deviceID() { + return Effect.gen(function* () { + const file = Bun.file(path.join(Global.Path.data, "kimi-code-device-id")) + if (yield* Effect.promise(() => file.exists())) { + const value = (yield* Effect.promise(() => file.text())).trim() + if (value) return value + } + const id = crypto.randomUUID() + yield* Effect.promise(() => Bun.write(file, id, { mode: 0o600 })) + return id + }) +} + +function formHeaders(headers: Record) { + return { ...headers, "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" } +} + +function ascii(value: string) { + return value.replaceAll(/[^\u0020-\u007E]/g, "").trim() || "unknown" +} + +function positiveSeconds(value: unknown, fallback: number) { + const seconds = Number(value) + return Number.isFinite(seconds) && seconds > 0 ? seconds : fallback +} diff --git a/packages/core/test/plugin/provider-kimi-for-coding.test.ts b/packages/core/test/plugin/provider-kimi-for-coding.test.ts new file mode 100644 index 0000000000..cd80d1476b --- /dev/null +++ b/packages/core/test/plugin/provider-kimi-for-coding.test.ts @@ -0,0 +1,105 @@ +import { Catalog } from "@opencode-ai/core/catalog" +import { Credential } from "@opencode-ai/core/credential" +import { Integration } from "@opencode-ai/core/integration" +import { ModelV2 } from "@opencode-ai/core/model" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" +import { KimiForCodingPlugin } from "@opencode-ai/core/plugin/provider/kimi-for-coding" +import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) +const providerID = ProviderV2.ID.make("kimi-for-coding") +const methodID = Integration.MethodID.make("device") + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + yield* KimiForCodingPlugin.effect(host) +}) + +const addProvider = Effect.fn(function* () { + const catalog = yield* Catalog.Service + yield* catalog.transform((draft) => { + draft.provider.update(providerID, (provider) => { + provider.name = "Kimi For Coding" + provider.package = ProviderV2.aisdk("@ai-sdk/anthropic") + provider.settings = { baseURL: "https://api.kimi.com/coding/v1" } + provider.headers = { "X-Custom": "preserved" } + }) + draft.model.update(providerID, ModelV2.ID.make("k3"), () => {}) + }) +}) + +describe("KimiForCodingPlugin", () => { + it.effect("runs before generic Anthropic and OpenAI-compatible transforms", () => + Effect.sync(() => { + const ids = ProviderPlugins.map((plugin) => plugin.id) + const index = ids.indexOf("opencode.provider.kimi-for-coding") + expect(index).toBeGreaterThanOrEqual(0) + expect(index).toBeLessThan(ids.indexOf("opencode.provider.anthropic")) + expect(index).toBeLessThan(ids.indexOf("opencode.provider.openai-compatible")) + }), + ) + + it.effect("registers Kimi Code subscription OAuth", () => + Effect.gen(function* () { + yield* addPlugin() + expect((yield* (yield* Integration.Service).get(Integration.ID.make("kimi-for-coding")))?.methods).toEqual([ + { + id: methodID, + type: "oauth", + label: "Kimi Code subscription (OAuth)", + }, + ]) + }), + ) + + it.effect("adds Kimi device identity headers for OAuth connections", () => + Effect.gen(function* () { + const credentials = yield* Credential.Service + const catalog = yield* Catalog.Service + yield* addProvider() + yield* credentials.create({ + integrationID: Integration.ID.make("kimi-for-coding"), + value: Credential.OAuth.make({ + type: "oauth", + methodID, + access: "access", + refresh: "refresh", + expires: Date.now() + 60_000, + }), + }) + yield* addPlugin() + + const provider = yield* catalog.provider.get(providerID) + expect(provider?.package).toBe(ProviderV2.aisdk("@ai-sdk/openai-compatible")) + expect(provider?.headers?.["X-Custom"]).toBe("preserved") + expect(provider?.headers?.["X-Msh-Platform"]).toBe("kimi_code_cli") + expect(provider?.headers?.["X-Msh-Device-Id"]).toMatch(/^[0-9a-f-]+$/) + expect(provider?.headers?.["User-Agent"]).toStartWith("opencode/") + expect(provider?.headers?.["Content-Type"]).toBeUndefined() + }), + ) + + it.effect("leaves API-key connections unchanged", () => + Effect.gen(function* () { + const credentials = yield* Credential.Service + const catalog = yield* Catalog.Service + yield* addProvider() + yield* credentials.create({ + integrationID: Integration.ID.make("kimi-for-coding"), + value: Credential.Key.make({ type: "key", key: "sk-kimi" }), + }) + yield* addPlugin() + + const provider = yield* catalog.provider.get(providerID) + expect(provider?.package).toBe(ProviderV2.aisdk("@ai-sdk/anthropic")) + expect(provider?.headers).toEqual({ "X-Custom": "preserved" }) + }), + ) +})