diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 079331cdb..eec0289d5 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed the `moonshot` provider being locked to the international Kimi host (`api.moonshot.ai`): OpenAI-completions requests now honor a `MOONSHOT_BASE_URL` override so users can reach the Kimi China platform (`api.moonshot.cn`), which rejects keys issued for the international endpoint. ([#2883](https://github.com/can1357/oh-my-pi/issues/2883)) + ## [16.1.8] - 2026-06-20 ### Changed diff --git a/packages/ai/test/issue-2883-moonshot-base-url.test.ts b/packages/ai/test/issue-2883-moonshot-base-url.test.ts new file mode 100644 index 000000000..66fe160c0 --- /dev/null +++ b/packages/ai/test/issue-2883-moonshot-base-url.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { resolveOpenAIRequestSetup } from "@oh-my-pi/pi-ai/providers/openai-shared"; + +const ORIGINAL_MOONSHOT_BASE_URL = Bun.env.MOONSHOT_BASE_URL; + +function restoreMoonshotBaseUrl(): void { + if (ORIGINAL_MOONSHOT_BASE_URL === undefined) { + delete Bun.env.MOONSHOT_BASE_URL; + return; + } + Bun.env.MOONSHOT_BASE_URL = ORIGINAL_MOONSHOT_BASE_URL; +} + +afterEach(() => { + restoreMoonshotBaseUrl(); +}); + +describe("Moonshot China base URL override (issue #2883)", () => { + // Mirrors the bundled `kimi-k2.7-code` catalog entry, whose baseUrl is + // hardcoded to the international platform (`api.moonshot.ai`). + const moonshotModel = { + provider: "moonshot", + id: "kimi-k2.7-code", + baseUrl: "https://api.moonshot.ai/v1", + }; + + test("redirects the moonshot provider to api.moonshot.cn when MOONSHOT_BASE_URL is set", () => { + Bun.env.MOONSHOT_BASE_URL = "https://api.moonshot.cn/v1"; + const setup = resolveOpenAIRequestSetup(moonshotModel, { + apiKey: "sk-china-key", + messages: [], + }); + expect(setup.baseUrl).toBe("https://api.moonshot.cn/v1"); + }); + + test("keeps the bundled international endpoint when MOONSHOT_BASE_URL is unset", () => { + delete Bun.env.MOONSHOT_BASE_URL; + const setup = resolveOpenAIRequestSetup(moonshotModel, { + apiKey: "sk-intl-key", + messages: [], + }); + expect(setup.baseUrl).toBe("https://api.moonshot.ai/v1"); + }); + + test("does not redirect other openai-completions providers", () => { + Bun.env.MOONSHOT_BASE_URL = "https://api.moonshot.cn/v1"; + const setup = resolveOpenAIRequestSetup( + { provider: "openai", id: "gpt-5.5", baseUrl: "https://api.openai.com/v1" }, + { apiKey: "sk-openai", messages: [] }, + ); + expect(setup.baseUrl).toBe("https://api.openai.com/v1"); + }); +}); diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index 20e561840..fc4cf28f6 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed the `moonshot` provider with no path to the Kimi China API: model discovery now honors a `MOONSHOT_BASE_URL` override (redirecting to `api.moonshot.cn`), and `KIMI_API_KEY` resolves as a fallback for `MOONSHOT_API_KEY`. ([#2883](https://github.com/can1357/oh-my-pi/issues/2883)) + ## [16.1.8] - 2026-06-20 ### Fixed diff --git a/packages/catalog/test/issue-2883-moonshot-china.test.ts b/packages/catalog/test/issue-2883-moonshot-china.test.ts new file mode 100644 index 000000000..cb0404e39 --- /dev/null +++ b/packages/catalog/test/issue-2883-moonshot-china.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, test, vi } from "bun:test"; +import { getCatalogProviderEntry } from "@oh-my-pi/pi-catalog/provider-models/descriptors"; +import { moonshotModelManagerOptions } from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; +import type { FetchImpl } from "@oh-my-pi/pi-catalog/types"; +import { $pickenv } from "@oh-my-pi/pi-utils"; + +const MODELS_DEV_URL = "https://models.dev/api.json"; + +const ORIGINAL_ENV: Record = { + MOONSHOT_BASE_URL: Bun.env.MOONSHOT_BASE_URL, + MOONSHOT_API_KEY: Bun.env.MOONSHOT_API_KEY, + KIMI_API_KEY: Bun.env.KIMI_API_KEY, +}; + +function restoreEnv(): void { + for (const key in ORIGINAL_ENV) { + const value = ORIGINAL_ENV[key]; + if (value === undefined) { + delete Bun.env[key]; + } else { + Bun.env[key] = value; + } + } +} + +function inputUrl(input: string | URL | Request): string { + if (typeof input === "string") return input; + if (input instanceof URL) return input.toString(); + return input.url; +} + +function makeFetchMock(calls: string[]): FetchImpl { + return vi.fn(async (input: string | URL | Request) => { + const url = inputUrl(input); + if (url === MODELS_DEV_URL) { + return new Response("{}", { status: 500 }); + } + calls.push(url); + return new Response(JSON.stringify({ data: [{ id: "kimi-k2.7-code" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as FetchImpl; +} + +afterEach(() => { + restoreEnv(); + vi.restoreAllMocks(); +}); + +describe("Moonshot China platform (issue #2883)", () => { + test("moonshot accepts KIMI_API_KEY as an env-key fallback for MOONSHOT_API_KEY", () => { + // `getEnvApiKey("moonshot")` resolves a catalog provider via `$pickenv(...envVars)` + // (see ai/src/stream.ts), so the descriptor's env-var order IS the resolution order. + const envVars = getCatalogProviderEntry("moonshot")?.envVars; + expect(envVars).toEqual(["MOONSHOT_API_KEY", "KIMI_API_KEY"]); + + delete Bun.env.MOONSHOT_API_KEY; + Bun.env.KIMI_API_KEY = "kimi-china-key"; + expect($pickenv(...(envVars ?? []))).toBe("kimi-china-key"); + }); + + test("MOONSHOT_API_KEY keeps precedence over the KIMI_API_KEY alias", () => { + const envVars = getCatalogProviderEntry("moonshot")?.envVars ?? []; + Bun.env.MOONSHOT_API_KEY = "moonshot-primary-key"; + Bun.env.KIMI_API_KEY = "kimi-fallback-key"; + expect($pickenv(...envVars)).toBe("moonshot-primary-key"); + }); + + test("discovers models against api.moonshot.cn when MOONSHOT_BASE_URL is set", async () => { + Bun.env.MOONSHOT_BASE_URL = "https://api.moonshot.cn/v1"; + const calls: string[] = []; + const options = moonshotModelManagerOptions({ apiKey: "kimi-china-key", fetch: makeFetchMock(calls) }); + const models = await options.fetchDynamicModels?.(); + + expect(calls).toContain("https://api.moonshot.cn/v1/models"); + expect(models?.[0]).toMatchObject({ + provider: "moonshot", + baseUrl: "https://api.moonshot.cn/v1", + }); + }); + + test("explicit config baseUrl keeps precedence over MOONSHOT_BASE_URL", async () => { + Bun.env.MOONSHOT_BASE_URL = "https://api.moonshot.cn/v1"; + const calls: string[] = []; + const options = moonshotModelManagerOptions({ + apiKey: "kimi-china-key", + baseUrl: "https://proxy.example/v1", + fetch: makeFetchMock(calls), + }); + const models = await options.fetchDynamicModels?.(); + + expect(calls).toContain("https://proxy.example/v1/models"); + expect(models?.[0]?.baseUrl).toBe("https://proxy.example/v1"); + }); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a43200ec3..009d8abd6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -7,9 +7,6 @@ - Fixed `omp list` and `omp remove` silently starting an interactive agent session (forwarding the bare verb to the model as a prompt) instead of surfacing the real `omp plugin list` / `omp plugin uninstall ` commands ([#2935](https://github.com/can1357/oh-my-pi/issues/2935)) - Fixed lazy-initialized LSP servers (basedpyright/pyright, and likely gopls/rust-analyzer) hanging on the first request: the message reader matched incoming messages against pending client requests by id before checking for a `method`, so a server-originated `workspace/configuration` pull whose id collided with an in-flight request was swallowed as a bogus response, leaving the pull unanswered and the server wedged. The reader now routes any message carrying a `method` as a server request before id-matching ([#3001](https://github.com/can1357/oh-my-pi/issues/3001)) - Fixed `omp --approval-mode=yolo acp` and other global option flags placed before a subcommand being rewritten to `launch` with the subcommand swallowed as prompt text; the CLI resolver now skips leading global flags (using the launch parser's value-consumption contract) and dispatches the real subcommand with the flags applied, so ACP mode honors the configured approval policy. ([#2970](https://github.com/can1357/oh-my-pi/issues/2970)) - -### Fixed - - Fixed `/mcp enable` and `/mcp disable` reconnecting unrelated MCP servers by scoping toggle reconnect/disconnect work to the named server. ([#3157](https://github.com/can1357/oh-my-pi/issues/3157)) ## [16.1.8] - 2026-06-20