diff --git a/docs/environment-variables.md b/docs/environment-variables.md index fd8c473f5a..2becaac845 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -62,6 +62,7 @@ These are consumed via `getEnvApiKey()` (`packages/ai/src/stream.ts`) unless not | `MISTRAL_API_KEY` | Mistral auth | Using Mistral models | | | `ZAI_API_KEY` | z.ai auth | Using z.ai models | Also used by z.ai web search provider | | `ZHIPU_API_KEY` | Zhipu Coding Plan auth | Using `zhipu-coding-plan` provider | | +| `UMANS_AI_CODING_PLAN_API_KEY` | Umans AI Coding Plan auth | Using `umans` provider | | | `MINIMAX_API_KEY` | MiniMax auth | Using `minimax` provider | | | `MINIMAX_CODE_API_KEY` | MiniMax Code auth | Using `minimax-code` provider | | | `MINIMAX_CODE_CN_API_KEY` | MiniMax Code CN auth | Using `minimax-code-cn` provider | | diff --git a/docs/providers.md b/docs/providers.md index 75c7476424..85c67c5ea5 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -114,6 +114,7 @@ Each provider has one or more environment variables that supply a key when no st | `zai` | `ZAI_API_KEY` | | `zenmux` | `ZENMUX_API_KEY` | | `zhipu-coding-plan` | `ZHIPU_API_KEY` | +| `umans` | `UMANS_AI_CODING_PLAN_API_KEY` | | `qianfan` | `QIANFAN_API_KEY` | | `qwen-portal` | `QWEN_OAUTH_TOKEN`, then `QWEN_PORTAL_API_KEY` | | `synthetic` | `SYNTHETIC_API_KEY` | diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index eb9c50d19f..dcd941ed00 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added Umans AI Coding Plan API-key login support and `UMANS_AI_CODING_PLAN_API_KEY` environment fallback ([#2636](https://github.com/can1357/oh-my-pi/pull/2636) by [@oldschoola](https://github.com/oldschoola)). + ## [15.13.3] - 2026-06-15 ### Added diff --git a/packages/ai/README.md b/packages/ai/README.md index 6decf9cbad..f485d96215 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -68,6 +68,7 @@ Unified LLM API with automatic model discovery, provider configuration, token an - **Kilo Gateway** (supports OAuth `/login kilo` or `KILO_API_KEY`) - **LiteLLM** (requires `LITELLM_API_KEY`) - **zAI** (requires `ZAI_API_KEY`) +- **Umans AI Coding Plan** (supports `/login umans` or `UMANS_AI_CODING_PLAN_API_KEY`) - **MiniMax Token Plan** (requires `MINIMAX_CODE_API_KEY` or `MINIMAX_CODE_CN_API_KEY`) - **Xiaomi MiMo** (requires `XIAOMI_API_KEY`) - **ZenMux** (requires `ZENMUX_API_KEY`) @@ -952,6 +953,7 @@ In Node.js environments, you can set environment variables to avoid passing API | Ollama Cloud | `OLLAMA_CLOUD_API_KEY` | | Qwen Portal | `QWEN_OAUTH_TOKEN` or `QWEN_PORTAL_API_KEY` | | zAI | `ZAI_API_KEY` | +| Umans AI Coding Plan | `UMANS_AI_CODING_PLAN_API_KEY` | | MiniMax Code | `MINIMAX_CODE_API_KEY` (international) or `MINIMAX_CODE_CN_API_KEY` (China) | | Xiaomi MiMo | `XIAOMI_API_KEY` | | ZenMux | `ZENMUX_API_KEY` | @@ -978,6 +980,7 @@ Provider endpoint defaults for the current OpenAI-compatible integrations: - Xiaomi MiMo: `https://api.xiaomimimo.com/anthropic` - ZenMux (OpenAI): `https://zenmux.ai/api/v1` - ZenMux (Anthropic models): `https://zenmux.ai/api/anthropic` +- Umans AI Coding Plan: `https://api.code.umans.ai` - vLLM: `http://127.0.0.1:8000/v1` - Ollama: local OpenAI-compatible runtime (`http://127.0.0.1:11434/v1`) - Ollama Cloud: native Ollama API host (`https://ollama.com/api`, configured here as base URL `https://ollama.com`) diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index ba73c497aa..60d82aa5eb 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -2382,10 +2382,9 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A }; } - // OpenCode Go's Anthropic-compatible gateway validates API-key auth through - // `X-Api-Key`; bearer-only requests reach the endpoint but return - // `Missing API key` before token validation. - if (model.provider === "opencode-go") { + // OpenCode Go and Umans validate Anthropic-compatible API-key auth through + // `X-Api-Key`; bearer-only requests reach the endpoint but fail auth. + if (model.provider === "opencode-go" || model.provider === "umans") { delete defaultHeaders.Authorization; return { isOAuthToken: false, diff --git a/packages/ai/src/registry/registry.ts b/packages/ai/src/registry/registry.ts index 288071422b..f1848b086c 100644 --- a/packages/ai/src/registry/registry.ts +++ b/packages/ai/src/registry/registry.ts @@ -46,6 +46,7 @@ import { syntheticProvider } from "./synthetic"; import { tavilyProvider } from "./tavily"; import { togetherProvider } from "./together"; import type { ProviderDefinition } from "./types"; +import { umansProvider } from "./umans"; import { veniceProvider } from "./venice"; import { vercelAiGatewayProvider } from "./vercel-ai-gateway"; import { vllmProvider } from "./vllm"; @@ -85,6 +86,7 @@ const ALL = [ alibabaCodingPlanProvider, aimlApiProvider, zhipuCodingPlanProvider, + umansProvider, qwenPortalProvider, minimaxCodeProvider, minimaxCodeCnProvider, diff --git a/packages/ai/src/registry/umans.ts b/packages/ai/src/registry/umans.ts new file mode 100644 index 0000000000..2231578cbd --- /dev/null +++ b/packages/ai/src/registry/umans.ts @@ -0,0 +1,23 @@ +import { createApiKeyLogin } from "./api-key-login"; +import type { OAuthLoginCallbacks } from "./oauth/types"; +import type { ProviderDefinition } from "./types"; + +export const loginUmans = createApiKeyLogin({ + providerLabel: "Umans AI Coding Plan", + authUrl: "https://app.umans.ai/billing", + instructions: "Create or copy your Umans API key from Dashboard → API Keys.", + promptMessage: "Paste your Umans API key", + placeholder: "sk-...", + validation: { + kind: "anthropic-messages", + provider: "Umans AI Coding Plan", + baseUrl: "https://api.code.umans.ai", + model: "umans-coder", + }, +}); + +export const umansProvider = { + id: "umans", + name: "Umans AI Coding Plan", + login: (cb: OAuthLoginCallbacks) => loginUmans(cb), +} as const satisfies ProviderDefinition; diff --git a/packages/ai/test/anthropic-alignment.test.ts b/packages/ai/test/anthropic-alignment.test.ts index 924d3211f8..827be66510 100644 --- a/packages/ai/test/anthropic-alignment.test.ts +++ b/packages/ai/test/anthropic-alignment.test.ts @@ -452,6 +452,23 @@ describe("Anthropic request fingerprint alignment", () => { expect(headers["X-Api-Key"]).toBeUndefined(); }); + it("keeps Umans Anthropic-compatible requests on X-Api-Key auth", () => { + const options = buildAnthropicClientOptions({ + model: buildModel({ + ...ANTHROPIC_MODEL_SPEC, + id: "umans-coder", + name: "Umans Coder", + provider: "umans", + baseUrl: "https://api.code.umans.ai", + }), + apiKey: "sk-umans-test", + stream: true, + }); + + expect(options.apiKey).toBe("sk-umans-test"); + expect(options.defaultHeaders.Authorization).toBeUndefined(); + }); + it("forwards only prefix-matching Claude Code User-Agent values", () => { const forwardedHeaders = buildAnthropicHeaders({ apiKey: "sk-ant-oat-test", diff --git a/packages/ai/test/provider-registry.test.ts b/packages/ai/test/provider-registry.test.ts index ee9bd8c7d5..b41c8a11b3 100644 --- a/packages/ai/test/provider-registry.test.ts +++ b/packages/ai/test/provider-registry.test.ts @@ -13,7 +13,7 @@ import type { OAuthCredentials, OAuthProvider } from "@oh-my-pi/pi-ai/registry/o import { getEnvApiKey } from "@oh-my-pi/pi-ai/stream"; const FIXTURE_SOURCE = "provider-registry-test"; -const ENV_KEYS = ["ZENMUX_API_KEY", "EXA_API_KEY", "XAI_OAUTH_TOKEN"] as const; +const ENV_KEYS = ["ZENMUX_API_KEY", "EXA_API_KEY", "XAI_OAUTH_TOKEN", "UMANS_AI_CODING_PLAN_API_KEY"] as const; const originalEnv = new Map(ENV_KEYS.map(key => [key, Bun.env[key]])); afterEach(() => { @@ -35,6 +35,8 @@ describe("provider registry auth surface", () => { Bun.env.EXA_API_KEY = "exa-env"; // Plain name derived from the catalog table's `envVars`. expect(getEnvApiKey("zenmux")).toBe("zenmux-env"); + Bun.env.UMANS_AI_CODING_PLAN_API_KEY = "umans-env"; + expect(getEnvApiKey("umans")).toBe("umans-env"); // Legacy search-tool key preserved (not a registry provider def). expect(getEnvApiKey("exa")).toBe("exa-env"); }); @@ -48,6 +50,7 @@ describe("provider registry auth surface", () => { const ids = getOAuthProviders().map(provider => provider.id); expect(ids).toContain("zenmux"); expect(ids).toContain("kagi"); + expect(ids).toContain("umans"); // openai has no interactive login flow. expect(ids).not.toContain("openai"); }); diff --git a/packages/ai/test/umans-login.test.ts b/packages/ai/test/umans-login.test.ts new file mode 100644 index 0000000000..955e627721 --- /dev/null +++ b/packages/ai/test/umans-login.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "bun:test"; +import { loginUmans } from "@oh-my-pi/pi-ai/registry/umans"; +import type { FetchImpl } from "@oh-my-pi/pi-ai/types"; + +describe("umans login", () => { + it("validates pasted keys against the Anthropic messages endpoint", async () => { + let authUrl: string | undefined; + let authInstructions: string | undefined; + let promptMessage: string | undefined; + let promptPlaceholder: string | undefined; + const fetchMock: FetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const headers = new Headers(init?.headers); + const body = JSON.parse(String(init?.body)) as { model?: string; max_tokens?: number }; + + expect(url).toBe("https://api.code.umans.ai/v1/messages"); + expect(init?.method).toBe("POST"); + expect(headers.get("content-type")).toBe("application/json"); + expect(headers.get("anthropic-version")).toBe("2023-06-01"); + expect(headers.get("x-api-key")).toBe("sk-umans-valid"); + expect(headers.get("authorization")).toBeNull(); + expect(body.model).toBe("umans-coder"); + expect(body.max_tokens).toBe(1); + + return new Response(JSON.stringify({ id: "msg_test", type: "message" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + const apiKey = await loginUmans({ + onAuth: info => { + authUrl = info.url; + authInstructions = info.instructions; + }, + onPrompt: async prompt => { + promptMessage = prompt.message; + promptPlaceholder = prompt.placeholder; + return " sk-umans-valid "; + }, + fetch: fetchMock, + }); + + expect(apiKey).toBe("sk-umans-valid"); + expect(authUrl).toBe("https://app.umans.ai/billing"); + expect(authInstructions).toContain("Dashboard → API Keys"); + expect(promptMessage).toBe("Paste your Umans API key"); + expect(promptPlaceholder).toBe("sk-..."); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("surfaces validation errors from the Anthropic messages endpoint", async () => { + const fetchMock: FetchImpl = vi.fn( + async () => + new Response("invalid key", { + status: 401, + headers: { "Content-Type": "text/plain" }, + }), + ); + + await expect( + loginUmans({ + onPrompt: async () => "sk-umans-bad", + fetch: fetchMock, + }), + ).rejects.toThrow("Umans AI Coding Plan API key validation failed (401): invalid key"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index f97a96c69a..2318812244 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added the Umans AI Coding Plan provider catalog with Anthropic-compatible model metadata and dynamic discovery ([#2636](https://github.com/can1357/oh-my-pi/pull/2636) by [@oldschoola](https://github.com/oldschoola)). + ## [15.13.3] - 2026-06-15 ### Added diff --git a/packages/catalog/scripts/generate-models.ts b/packages/catalog/scripts/generate-models.ts index 777058774f..45bd371a4a 100644 --- a/packages/catalog/scripts/generate-models.ts +++ b/packages/catalog/scripts/generate-models.ts @@ -307,6 +307,19 @@ function dropFireworksWireIds(models: readonly ModelSpec[]): ModelSpec[] { ); } +/** + * Xiaomi's `/v1/models` can advertise ASR/TTS ids alongside chat/completions + * models. Runtime discovery filters them, but previous bundled snapshots can + * still resurrect those stale ids via the fallback merge. Drop them here so the + * committed catalog matches the runtime surface. + */ +function dropXiaomiAudioOnlyIds(models: readonly ModelSpec[]): ModelSpec[] { + return models.filter(model => { + const isXiaomiProvider = model.provider === "xiaomi" || model.provider.startsWith("xiaomi-token-plan-"); + return !isXiaomiProvider || (!model.id.includes("-tts") && !model.id.includes("-asr")); + }); +} + const ANTIGRAVITY_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com"; async function getOAuthAccessFromStorage(provider: OAuthProvider): Promise { @@ -496,6 +509,7 @@ async function generateModels() { allModels = applyFireworksDeepSeekReasoningShape(allModels); allModels = dropFireworksWireIds(allModels); allModels = dropUnusableZaiContextTierIds(allModels); + allModels = dropXiaomiAudioOnlyIds(allModels); // Normalize display names: gateway author prefixes ("OpenAI: …"), alias // markers ("(latest)"), provider attribution ("(Antigravity)"), and // price/promo tags are model-extrinsic — strip them from the bundle. diff --git a/packages/catalog/scripts/generated-policies.ts b/packages/catalog/scripts/generated-policies.ts index ba6fbdbf65..330ff4ae4b 100644 --- a/packages/catalog/scripts/generated-policies.ts +++ b/packages/catalog/scripts/generated-policies.ts @@ -82,9 +82,11 @@ export function applyGeneratedModelPolicies(models: ModelSpec[]): void { */ export function rebakeModelThinking(model: ModelSpec): void { if (isVariantCollapsedSpec(model)) return; + const requiresProviderAuthoredEffort = + model.provider === "umans" && (model.thinking?.requiresEffort === true || model.id === "umans-kimi-k2.7"); const thinking = resolveModelThinking({ ...model, thinking: undefined }, buildCompat(model)); if (thinking) { - model.thinking = thinking; + model.thinking = requiresProviderAuthoredEffort ? { ...thinking, requiresEffort: true } : thinking; } else { delete model.thinking; } diff --git a/packages/catalog/src/identity/priority.ts b/packages/catalog/src/identity/priority.ts index 9e012f6696..6612c9e7b9 100644 --- a/packages/catalog/src/identity/priority.ts +++ b/packages/catalog/src/identity/priority.ts @@ -27,6 +27,7 @@ const DEFAULT_MODEL_PROVIDER_ORDER = [ // Generic gateways and editor/proxy providers. These are useful when picked // explicitly, but should not win ambiguous automatic role selection. "alibaba-coding-plan", + "umans", "google-antigravity", "opencode-zen", "gitlab-duo", diff --git a/packages/catalog/src/models.json b/packages/catalog/src/models.json index f04b5117e3..c606f956c3 100644 --- a/packages/catalog/src/models.json +++ b/packages/catalog/src/models.json @@ -68887,6 +68887,189 @@ } } }, + "umans": { + "umans-coder": { + "id": "umans-coder", + "name": "Umans Coder", + "api": "anthropic-messages", + "provider": "umans", + "baseUrl": "https://api.code.umans.ai", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 262144, + "thinking": { + "mode": "budget", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + } + }, + "umans-flash": { + "id": "umans-flash", + "name": "Umans Flash", + "api": "anthropic-messages", + "provider": "umans", + "baseUrl": "https://api.code.umans.ai", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 262144, + "thinking": { + "mode": "budget", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + } + }, + "umans-glm-5.1": { + "id": "umans-glm-5.1", + "name": "GLM 5.1", + "api": "anthropic-messages", + "provider": "umans", + "baseUrl": "https://api.code.umans.ai", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 204800, + "maxTokens": 131072, + "thinking": { + "mode": "budget", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + } + }, + "umans-kimi-k2.6": { + "id": "umans-kimi-k2.6", + "name": "Kimi K2.6", + "api": "anthropic-messages", + "provider": "umans", + "baseUrl": "https://api.code.umans.ai", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 262144, + "thinking": { + "mode": "budget", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + } + }, + "umans-kimi-k2.7": { + "id": "umans-kimi-k2.7", + "name": "Kimi K2.7 Code", + "api": "anthropic-messages", + "provider": "umans", + "baseUrl": "https://api.code.umans.ai", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 262144, + "thinking": { + "mode": "budget", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "requiresEffort": true + } + }, + "umans-qwen3.6-35b-a3b": { + "id": "umans-qwen3.6-35b-a3b", + "name": "Qwen3.6 35B A3B", + "api": "anthropic-messages", + "provider": "umans", + "baseUrl": "https://api.code.umans.ai", + "reasoning": true, + "input": [ + "text", + "image" + ], + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + }, + "contextWindow": 262144, + "maxTokens": 262144, + "thinking": { + "mode": "budget", + "efforts": [ + "minimal", + "low", + "medium", + "high", + "xhigh" + ] + } + } + }, "venice": { "aion-labs-aion-2-0": { "id": "aion-labs-aion-2-0", diff --git a/packages/catalog/src/provider-models/descriptors.ts b/packages/catalog/src/provider-models/descriptors.ts index 51be92e9ac..e1d8600a05 100644 --- a/packages/catalog/src/provider-models/descriptors.ts +++ b/packages/catalog/src/provider-models/descriptors.ts @@ -37,6 +37,7 @@ import { qwenPortalModelManagerOptions, syntheticModelManagerOptions, togetherModelManagerOptions, + umansModelManagerOptions, veniceModelManagerOptions, vercelAiGatewayModelManagerOptions, vllmModelManagerOptions, @@ -313,6 +314,14 @@ export const CATALOG_PROVIDERS = [ createModelManagerOptions: (config: ModelManagerConfig) => togetherModelManagerOptions(config), catalogDiscovery: { label: "Together" }, }, + { + id: "umans", + defaultModel: "umans-coder", + envVars: ["UMANS_AI_CODING_PLAN_API_KEY"], + createModelManagerOptions: (config: ModelManagerConfig) => umansModelManagerOptions(config), + dynamicModelsAuthoritative: true, + catalogDiscovery: { label: "Umans AI Coding Plan", allowUnauthenticated: true }, + }, { id: "venice", defaultModel: "llama-3.3-70b", diff --git a/packages/catalog/src/provider-models/openai-compat.ts b/packages/catalog/src/provider-models/openai-compat.ts index d35e4babb0..2766d8ae2a 100644 --- a/packages/catalog/src/provider-models/openai-compat.ts +++ b/packages/catalog/src/provider-models/openai-compat.ts @@ -568,6 +568,155 @@ function createSimpleAnthropicProviderOptions( }; } +// --------------------------------------------------------------------------- +// Umans AI Coding Plan +// --------------------------------------------------------------------------- + +const UMANS_BASE_URL = "https://api.code.umans.ai"; +const UMANS_MODELS_INFO_PATH = "/models/info"; +const UMANS_REASONING_EFFORT_BY_LEVEL: Record = { + minimal: Effort.Minimal, + low: Effort.Low, + medium: Effort.Medium, + high: Effort.High, + xhigh: Effort.XHigh, +}; +const UMANS_DEFAULT_REASONING_EFFORTS = [Effort.Minimal, Effort.Low, Effort.Medium, Effort.High, Effort.XHigh] as const; + +export interface UmansModelManagerConfig { + apiKey?: string; + baseUrl?: string; + fetch?: FetchImpl; +} + +interface UmansModelInfo { + name?: unknown; + display_name?: unknown; + capabilities?: unknown; +} + +function normalizeUmansBaseUrl(baseUrl: string | undefined): string { + const normalized = normalizeAnthropicBaseUrl(baseUrl, UMANS_BASE_URL); + return normalized.endsWith("/v1") ? normalized.slice(0, -3) : normalized; +} + +function umansSupportsVision(value: unknown): boolean { + return value === true || (typeof value === "string" && value.length > 0); +} + +function umansReasoningSupported(value: unknown): boolean { + return isRecord(value) ? value.supported === true : value === true; +} + +function mapUmansReasoningEfforts(value: unknown): readonly Effort[] { + if (!isRecord(value) || !Array.isArray(value.levels)) { + return UMANS_DEFAULT_REASONING_EFFORTS; + } + const efforts: Effort[] = []; + for (const level of value.levels) { + if (typeof level !== "string") continue; + const effort = UMANS_REASONING_EFFORT_BY_LEVEL[level]; + if (effort !== undefined && !efforts.includes(effort)) { + efforts.push(effort); + } + } + return efforts.length > 0 ? efforts : UMANS_DEFAULT_REASONING_EFFORTS; +} + +function mapUmansThinkingConfig(value: unknown): ThinkingConfig | undefined { + if (!umansReasoningSupported(value)) return undefined; + const efforts = mapUmansReasoningEfforts(value); + const thinking: ThinkingConfig = { mode: "budget", efforts }; + if (isRecord(value)) { + if (value.can_disable === false) { + thinking.requiresEffort = true; + } + if (typeof value.default_level === "string") { + const defaultLevel = UMANS_REASONING_EFFORT_BY_LEVEL[value.default_level]; + if (defaultLevel !== undefined && efforts.includes(defaultLevel)) { + thinking.defaultLevel = defaultLevel; + } + } + } + return thinking; +} + +function mapUmansModelInfo( + modelId: string, + raw: UmansModelInfo, + baseUrl: string, + reference: ModelSpec<"anthropic-messages"> | undefined, +): ModelSpec<"anthropic-messages"> | null { + if (!modelId) return null; + const capabilities = isRecord(raw.capabilities) ? raw.capabilities : {}; + const supportsTools = capabilities.supports_tools; + const thinking = mapUmansThinkingConfig(capabilities.reasoning); + return { + ...reference, + id: modelId, + name: toModelName(raw.display_name, toModelName(raw.name, modelId)), + api: "anthropic-messages", + provider: "umans", + baseUrl, + reasoning: thinking !== undefined, + ...(thinking ? { thinking } : {}), + input: umansSupportsVision(capabilities.supports_vision) ? ["text", "image"] : ["text"], + ...(supportsTools === false ? { supportsTools: false } : {}), + cost: reference?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: toPositiveNumber(capabilities.context_window, reference?.contextWindow ?? null), + maxTokens: toPositiveNumber(capabilities.max_completion_tokens, reference?.maxTokens ?? null), + }; +} + +async function fetchUmansModelsInfo(options: { + baseUrl: string; + apiKey?: string; + fetch?: FetchImpl; + references: Map>; +}): Promise[] | null> { + const discoveryBaseUrl = toAnthropicDiscoveryBaseUrl(options.baseUrl); + const requestHeaders: Record = { Accept: "application/json" }; + if (options.apiKey) { + requestHeaders["x-api-key"] = options.apiKey; + } + const fetchImpl = options.fetch ?? fetch; + let payload: unknown; + try { + const response = await fetchImpl(`${discoveryBaseUrl}${UMANS_MODELS_INFO_PATH}`, { + method: "GET", + headers: requestHeaders, + }); + if (!response.ok) { + return null; + } + payload = await response.json(); + } catch (error) { + throw new Error("Failed to fetch Umans models info", { cause: error }); + } + if (!isRecord(payload)) { + return null; + } + const models: ModelSpec<"anthropic-messages">[] = []; + for (const [modelId, value] of Object.entries(payload)) { + if (!isRecord(value)) continue; + const mapped = mapUmansModelInfo(modelId, value, options.baseUrl, options.references.get(modelId)); + if (mapped) { + models.push(mapped); + } + } + return models.sort((left, right) => left.id.localeCompare(right.id)); +} + +export function umansModelManagerOptions(config?: UmansModelManagerConfig): ModelManagerOptions<"anthropic-messages"> { + const apiKey = config?.apiKey; + const baseUrl = normalizeUmansBaseUrl(config?.baseUrl); + const references = createBundledReferenceMap<"anthropic-messages">("umans"); + return { + providerId: "umans", + dynamicModelsAuthoritative: true, + fetchDynamicModels: () => fetchUmansModelsInfo({ baseUrl, apiKey, fetch: config?.fetch, references }), + }; +} // --------------------------------------------------------------------------- // 1. OpenAI // --------------------------------------------------------------------------- @@ -2307,7 +2456,7 @@ export function xiaomiModelManagerOptions( provider: providerId, baseUrl: url, apiKey, - filterModel: (_entry, model) => !model.id.includes("-tts"), + filterModel: (_entry, model) => !model.id.includes("-tts") && !model.id.includes("-asr"), mapModel: (entry, defaults) => { const reference = references.get(defaults.id); const model = mapWithBundledReference(entry, defaults, reference); @@ -3245,6 +3394,8 @@ const MODELS_DEV_PROVIDER_DESCRIPTORS_CORE: readonly ModelsDevProviderDescriptor const MODELS_DEV_PROVIDER_DESCRIPTORS_CODING_PLANS: readonly ModelsDevProviderDescriptor[] = [ // --- zAI --- anthropicMessagesDescriptor("zai-coding-plan", "zai", "https://api.z.ai/api/anthropic"), + // --- Umans AI Coding Plan --- + anthropicMessagesDescriptor("umans-ai-coding-plan", "umans", UMANS_BASE_URL), // --- Xiaomi --- openAiCompletionsDescriptor("xiaomi", "xiaomi", "https://api.xiaomimimo.com/v1", { defaultContextWindow: 262144, diff --git a/packages/catalog/test/issue-772-repro.test.ts b/packages/catalog/test/issue-772-repro.test.ts index 217725f1c4..c23cc07e8c 100644 --- a/packages/catalog/test/issue-772-repro.test.ts +++ b/packages/catalog/test/issue-772-repro.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test"; import { loginXiaomi } from "@oh-my-pi/pi-ai/registry/oauth/xiaomi"; import { xiaomiModelManagerOptions } from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; import type { FetchImpl } from "@oh-my-pi/pi-catalog/types"; +import modelsJson from "../src/models.json"; const TOKEN_PLAN_SGP_HOST = "token-plan-sgp.xiaomimimo.com"; const STANDARD_HOST = "api.xiaomimimo.com"; @@ -64,4 +65,32 @@ describe("issue-772: Xiaomi MiMo token-plan (tp-) keys", () => { expect(url).toContain(STANDARD_HOST); expect(url).not.toContain(TOKEN_PLAN_SGP_HOST); }); + + it("filters Xiaomi ASR and TTS-only models from discovery", async () => { + const fetchMock: FetchImpl = async () => + new Response( + JSON.stringify({ + data: [ + { id: "mimo-v2.5-asr", name: "mimo-v2.5-asr" }, + { id: "mimo-v2.5-tts", name: "mimo-v2.5-tts" }, + { id: "mimo-v2.5", name: "MiMo-V2.5" }, + ], + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ); + + const models = await xiaomiModelManagerOptions({ + apiKey: "sk-test-key", + fetch: fetchMock, + }).fetchDynamicModels?.(); + + expect(models?.map(model => model.id)).toEqual(["mimo-v2.5"]); + }); + + it("does not bundle Xiaomi ASR-only models", () => { + expect(Object.keys(modelsJson.xiaomi)).not.toContain("mimo-v2.5-asr"); + }); }); diff --git a/packages/catalog/test/umans-provider.test.ts b/packages/catalog/test/umans-provider.test.ts new file mode 100644 index 0000000000..f36ce803ce --- /dev/null +++ b/packages/catalog/test/umans-provider.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "bun:test"; +import { + MODELS_DEV_PROVIDER_DESCRIPTORS, + mapModelsDevToModels, + umansModelManagerOptions, +} from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; +import type { FetchImpl } from "@oh-my-pi/pi-catalog/types"; +import modelsJson from "../src/models.json"; + +interface BundledModel { + api: string; + provider: string; + baseUrl: string; + reasoning: boolean; + input: string[]; + contextWindow: number | null; + maxTokens: number | null; + thinking?: { + defaultLevel?: string; + requiresEffort?: boolean; + }; +} + +describe("umans provider catalog", () => { + it("discovers Anthropic-route models from the public models info endpoint", async () => { + const requestedUrls: string[] = []; + const fetchImpl: FetchImpl = async input => { + requestedUrls.push(String(input)); + return new Response( + JSON.stringify({ + "umans-coder": { + display_name: "Umans Coder", + capabilities: { + context_window: 262_144, + max_completion_tokens: 262_144, + supports_vision: true, + supports_tools: true, + reasoning: { supported: true, can_disable: true, default_level: "medium" }, + }, + }, + "umans-kimi-k2.7": { + display_name: "Umans Kimi K2.7 Code", + capabilities: { + context_window: 262_144, + max_completion_tokens: 262_144, + supports_vision: true, + supports_tools: true, + reasoning: { supported: true, can_disable: false, default_level: "medium" }, + }, + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }; + + const options = umansModelManagerOptions({ fetch: fetchImpl }); + const fetchDynamicModels = options.fetchDynamicModels; + if (!fetchDynamicModels) throw new Error("Umans dynamic discovery is not configured"); + + const models = await fetchDynamicModels(); + + expect(requestedUrls).toEqual(["https://api.code.umans.ai/v1/models/info"]); + expect(models).not.toBeNull(); + const model = models?.find(item => item.id === "umans-coder"); + expect(model).toMatchObject({ + id: "umans-coder", + name: "Umans Coder", + api: "anthropic-messages", + provider: "umans", + baseUrl: "https://api.code.umans.ai", + reasoning: true, + input: ["text", "image"], + contextWindow: 262_144, + maxTokens: 262_144, + thinking: { defaultLevel: "medium" }, + }); + const mandatoryReasoningModel = models?.find(item => item.id === "umans-kimi-k2.7"); + expect(mandatoryReasoningModel).toMatchObject({ + id: "umans-kimi-k2.7", + reasoning: true, + thinking: { defaultLevel: "medium", requiresEffort: true }, + }); + }); + + it("surfaces Umans discovery fetch failures", async () => { + const fetchDynamicModels = umansModelManagerOptions({ + fetch: async () => { + throw new Error("boom"); + }, + }).fetchDynamicModels; + if (!fetchDynamicModels) throw new Error("Umans dynamic discovery is not configured"); + + await expect(fetchDynamicModels()).rejects.toThrow("Failed to fetch Umans models info"); + }); + + it("maps the models.dev Umans provider to the Anthropic endpoint", () => { + const models = mapModelsDevToModels( + { + "umans-ai-coding-plan": { + models: { + "umans-coder": { + name: "Umans Coder", + tool_call: true, + reasoning: true, + modalities: { input: ["text", "image"] }, + limit: { context: 262_144, output: 262_144 }, + cost: { input: 0, output: 0, cache_read: 0, cache_write: 0 }, + }, + }, + }, + }, + MODELS_DEV_PROVIDER_DESCRIPTORS, + ).filter(model => model.provider === "umans"); + + expect(models).toHaveLength(1); + expect(models[0]).toMatchObject({ + id: "umans-coder", + api: "anthropic-messages", + provider: "umans", + baseUrl: "https://api.code.umans.ai", + reasoning: true, + input: ["text", "image"], + contextWindow: 262_144, + maxTokens: 262_144, + }); + }); + + it("bundles the default Umans coding model", () => { + const providers = modelsJson as Record>; + const model = providers.umans?.["umans-coder"]; + + expect(model).toBeDefined(); + expect(model).toMatchObject({ + api: "anthropic-messages", + provider: "umans", + baseUrl: "https://api.code.umans.ai", + reasoning: true, + input: ["text", "image"], + contextWindow: 262_144, + maxTokens: 262_144, + }); + }); + + it("bundles Umans mandatory reasoning metadata", () => { + const providers = modelsJson as Record>; + const model = providers.umans?.["umans-kimi-k2.7"]; + + expect(model).toBeDefined(); + expect(model.thinking).toMatchObject({ + requiresEffort: true, + }); + }); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 93cfaebf9f..9b2c627a64 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `UMANS_AI_CODING_PLAN_API_KEY` to the CLI environment help ([#2636](https://github.com/can1357/oh-my-pi/pull/2636) by [@oldschoola](https://github.com/oldschoola)). + ## [15.13.3] - 2026-06-15 ### Added diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 10efe1ffa8..f8a32ae06a 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -279,6 +279,7 @@ export function getExtraHelpText(): string { KILO_API_KEY - Kilo Gateway models MISTRAL_API_KEY - Mistral models ZAI_API_KEY - z.ai models (ZhipuAI/GLM) + UMANS_AI_CODING_PLAN_API_KEY - Umans AI Coding Plan models MINIMAX_API_KEY - MiniMax models OPENCODE_API_KEY - OpenCode Zen/OpenCode Go models CURSOR_ACCESS_TOKEN - Cursor AI models