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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | |
Expand Down
1 change: 1 addition & 0 deletions docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
4 changes: 4 additions & 0 deletions packages/ai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/ai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down Expand Up @@ -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` |
Expand All @@ -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`)
Expand Down
7 changes: 3 additions & 4 deletions packages/ai/src/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/ai/src/registry/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -85,6 +86,7 @@ const ALL = [
alibabaCodingPlanProvider,
aimlApiProvider,
zhipuCodingPlanProvider,
umansProvider,
qwenPortalProvider,
minimaxCodeProvider,
minimaxCodeCnProvider,
Expand Down
23 changes: 23 additions & 0 deletions packages/ai/src/registry/umans.ts
Original file line number Diff line number Diff line change
@@ -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: {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should-fix: /login umans is a new user-facing path, but the PR only checks that umans appears in the provider list. Existing API-key providers such as zenmux and nanogpt have tests that assert the exact validation URL, method, auth header, prompt text, trimmed key, and validation-error surfacing. Without the same coverage here, a typo in baseUrl, model, or the Anthropic x-api-key validation path would ship while provider-registry.test.ts still passes.

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;
17 changes: 17 additions & 0 deletions packages/ai/test/anthropic-alignment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion packages/ai/test/provider-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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");
});
Expand All @@ -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");
});
Expand Down
69 changes: 69 additions & 0 deletions packages/ai/test/umans-login.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
4 changes: 4 additions & 0 deletions packages/catalog/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions packages/catalog/scripts/generate-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OAuthAccess | null> {
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion packages/catalog/scripts/generated-policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,11 @@ export function applyGeneratedModelPolicies(models: ModelSpec<Api>[]): void {
*/
export function rebakeModelThinking(model: ModelSpec<Api>): 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;
}
Expand Down
1 change: 1 addition & 0 deletions packages/catalog/src/identity/priority.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading