Skip to content

Commit fcde870

Browse files
committed
Merge PR #2636: Add Umans AI Coding Plan provider
2 parents 5272e56 + 1c4bda2 commit fcde870

21 files changed

Lines changed: 680 additions & 7 deletions

docs/environment-variables.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ These are consumed via `getEnvApiKey()` (`packages/ai/src/stream.ts`) unless not
6262
| `MISTRAL_API_KEY` | Mistral auth | Using Mistral models | |
6363
| `ZAI_API_KEY` | z.ai auth | Using z.ai models | Also used by z.ai web search provider |
6464
| `ZHIPU_API_KEY` | Zhipu Coding Plan auth | Using `zhipu-coding-plan` provider | |
65+
| `UMANS_AI_CODING_PLAN_API_KEY` | Umans AI Coding Plan auth | Using `umans` provider | |
6566
| `MINIMAX_API_KEY` | MiniMax auth | Using `minimax` provider | |
6667
| `MINIMAX_CODE_API_KEY` | MiniMax Code auth | Using `minimax-code` provider | |
6768
| `MINIMAX_CODE_CN_API_KEY` | MiniMax Code CN auth | Using `minimax-code-cn` provider | |

docs/providers.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ Each provider has one or more environment variables that supply a key when no st
114114
| `zai` | `ZAI_API_KEY` |
115115
| `zenmux` | `ZENMUX_API_KEY` |
116116
| `zhipu-coding-plan` | `ZHIPU_API_KEY` |
117+
| `umans` | `UMANS_AI_CODING_PLAN_API_KEY` |
117118
| `qianfan` | `QIANFAN_API_KEY` |
118119
| `qwen-portal` | `QWEN_OAUTH_TOKEN`, then `QWEN_PORTAL_API_KEY` |
119120
| `synthetic` | `SYNTHETIC_API_KEY` |

packages/ai/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@
4747
### Fixed
4848

4949
- Fixed OpenAI Responses, Azure OpenAI Responses, and Codex Responses providers ignoring async `onPayload` replacement bodies. Provider payload hooks can now transform the actual request body sent upstream, matching the Anthropic/Gemini replacement contract.
50+
### Added
51+
52+
- 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)).
53+
5054
## [15.13.3] - 2026-06-15
5155

5256
### Added

packages/ai/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ Unified LLM API with automatic model discovery, provider configuration, token an
6868
- **Kilo Gateway** (supports OAuth `/login kilo` or `KILO_API_KEY`)
6969
- **LiteLLM** (requires `LITELLM_API_KEY`)
7070
- **zAI** (requires `ZAI_API_KEY`)
71+
- **Umans AI Coding Plan** (supports `/login umans` or `UMANS_AI_CODING_PLAN_API_KEY`)
7172
- **MiniMax Token Plan** (requires `MINIMAX_CODE_API_KEY` or `MINIMAX_CODE_CN_API_KEY`)
7273
- **Xiaomi MiMo** (requires `XIAOMI_API_KEY`)
7374
- **ZenMux** (requires `ZENMUX_API_KEY`)
@@ -952,6 +953,7 @@ In Node.js environments, you can set environment variables to avoid passing API
952953
| Ollama Cloud | `OLLAMA_CLOUD_API_KEY` |
953954
| Qwen Portal | `QWEN_OAUTH_TOKEN` or `QWEN_PORTAL_API_KEY` |
954955
| zAI | `ZAI_API_KEY` |
956+
| Umans AI Coding Plan | `UMANS_AI_CODING_PLAN_API_KEY` |
955957
| MiniMax Code | `MINIMAX_CODE_API_KEY` (international) or `MINIMAX_CODE_CN_API_KEY` (China) |
956958
| Xiaomi MiMo | `XIAOMI_API_KEY` |
957959
| ZenMux | `ZENMUX_API_KEY` |
@@ -978,6 +980,7 @@ Provider endpoint defaults for the current OpenAI-compatible integrations:
978980
- Xiaomi MiMo: `https://api.xiaomimimo.com/anthropic`
979981
- ZenMux (OpenAI): `https://zenmux.ai/api/v1`
980982
- ZenMux (Anthropic models): `https://zenmux.ai/api/anthropic`
983+
- Umans AI Coding Plan: `https://api.code.umans.ai`
981984
- vLLM: `http://127.0.0.1:8000/v1`
982985
- Ollama: local OpenAI-compatible runtime (`http://127.0.0.1:11434/v1`)
983986
- Ollama Cloud: native Ollama API host (`https://ollama.com/api`, configured here as base URL `https://ollama.com`)

packages/ai/src/providers/anthropic.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2382,10 +2382,9 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
23822382
};
23832383
}
23842384

2385-
// OpenCode Go's Anthropic-compatible gateway validates API-key auth through
2386-
// `X-Api-Key`; bearer-only requests reach the endpoint but return
2387-
// `Missing API key` before token validation.
2388-
if (model.provider === "opencode-go") {
2385+
// OpenCode Go and Umans validate Anthropic-compatible API-key auth through
2386+
// `X-Api-Key`; bearer-only requests reach the endpoint but fail auth.
2387+
if (model.provider === "opencode-go" || model.provider === "umans") {
23892388
delete defaultHeaders.Authorization;
23902389
return {
23912390
isOAuthToken: false,

packages/ai/src/registry/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import { syntheticProvider } from "./synthetic";
4646
import { tavilyProvider } from "./tavily";
4747
import { togetherProvider } from "./together";
4848
import type { ProviderDefinition } from "./types";
49+
import { umansProvider } from "./umans";
4950
import { veniceProvider } from "./venice";
5051
import { vercelAiGatewayProvider } from "./vercel-ai-gateway";
5152
import { vllmProvider } from "./vllm";
@@ -85,6 +86,7 @@ const ALL = [
8586
alibabaCodingPlanProvider,
8687
aimlApiProvider,
8788
zhipuCodingPlanProvider,
89+
umansProvider,
8890
qwenPortalProvider,
8991
minimaxCodeProvider,
9092
minimaxCodeCnProvider,

packages/ai/src/registry/umans.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { createApiKeyLogin } from "./api-key-login";
2+
import type { OAuthLoginCallbacks } from "./oauth/types";
3+
import type { ProviderDefinition } from "./types";
4+
5+
export const loginUmans = createApiKeyLogin({
6+
providerLabel: "Umans AI Coding Plan",
7+
authUrl: "https://app.umans.ai/billing",
8+
instructions: "Create or copy your Umans API key from Dashboard → API Keys.",
9+
promptMessage: "Paste your Umans API key",
10+
placeholder: "sk-...",
11+
validation: {
12+
kind: "anthropic-messages",
13+
provider: "Umans AI Coding Plan",
14+
baseUrl: "https://api.code.umans.ai",
15+
model: "umans-coder",
16+
},
17+
});
18+
19+
export const umansProvider = {
20+
id: "umans",
21+
name: "Umans AI Coding Plan",
22+
login: (cb: OAuthLoginCallbacks) => loginUmans(cb),
23+
} as const satisfies ProviderDefinition;

packages/ai/test/anthropic-alignment.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,23 @@ describe("Anthropic request fingerprint alignment", () => {
452452
expect(headers["X-Api-Key"]).toBeUndefined();
453453
});
454454

455+
it("keeps Umans Anthropic-compatible requests on X-Api-Key auth", () => {
456+
const options = buildAnthropicClientOptions({
457+
model: buildModel({
458+
...ANTHROPIC_MODEL_SPEC,
459+
id: "umans-coder",
460+
name: "Umans Coder",
461+
provider: "umans",
462+
baseUrl: "https://api.code.umans.ai",
463+
}),
464+
apiKey: "sk-umans-test",
465+
stream: true,
466+
});
467+
468+
expect(options.apiKey).toBe("sk-umans-test");
469+
expect(options.defaultHeaders.Authorization).toBeUndefined();
470+
});
471+
455472
it("forwards only prefix-matching Claude Code User-Agent values", () => {
456473
const forwardedHeaders = buildAnthropicHeaders({
457474
apiKey: "sk-ant-oat-test",

packages/ai/test/provider-registry.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import type { OAuthCredentials, OAuthProvider } from "@oh-my-pi/pi-ai/registry/o
1313
import { getEnvApiKey } from "@oh-my-pi/pi-ai/stream";
1414

1515
const FIXTURE_SOURCE = "provider-registry-test";
16-
const ENV_KEYS = ["ZENMUX_API_KEY", "EXA_API_KEY", "XAI_OAUTH_TOKEN"] as const;
16+
const ENV_KEYS = ["ZENMUX_API_KEY", "EXA_API_KEY", "XAI_OAUTH_TOKEN", "UMANS_AI_CODING_PLAN_API_KEY"] as const;
1717
const originalEnv = new Map(ENV_KEYS.map(key => [key, Bun.env[key]]));
1818

1919
afterEach(() => {
@@ -35,6 +35,8 @@ describe("provider registry auth surface", () => {
3535
Bun.env.EXA_API_KEY = "exa-env";
3636
// Plain name derived from the catalog table's `envVars`.
3737
expect(getEnvApiKey("zenmux")).toBe("zenmux-env");
38+
Bun.env.UMANS_AI_CODING_PLAN_API_KEY = "umans-env";
39+
expect(getEnvApiKey("umans")).toBe("umans-env");
3840
// Legacy search-tool key preserved (not a registry provider def).
3941
expect(getEnvApiKey("exa")).toBe("exa-env");
4042
});
@@ -48,6 +50,7 @@ describe("provider registry auth surface", () => {
4850
const ids = getOAuthProviders().map(provider => provider.id);
4951
expect(ids).toContain("zenmux");
5052
expect(ids).toContain("kagi");
53+
expect(ids).toContain("umans");
5154
// openai has no interactive login flow.
5255
expect(ids).not.toContain("openai");
5356
});
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, expect, it, vi } from "bun:test";
2+
import { loginUmans } from "@oh-my-pi/pi-ai/registry/umans";
3+
import type { FetchImpl } from "@oh-my-pi/pi-ai/types";
4+
5+
describe("umans login", () => {
6+
it("validates pasted keys against the Anthropic messages endpoint", async () => {
7+
let authUrl: string | undefined;
8+
let authInstructions: string | undefined;
9+
let promptMessage: string | undefined;
10+
let promptPlaceholder: string | undefined;
11+
const fetchMock: FetchImpl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
12+
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
13+
const headers = new Headers(init?.headers);
14+
const body = JSON.parse(String(init?.body)) as { model?: string; max_tokens?: number };
15+
16+
expect(url).toBe("https://api.code.umans.ai/v1/messages");
17+
expect(init?.method).toBe("POST");
18+
expect(headers.get("content-type")).toBe("application/json");
19+
expect(headers.get("anthropic-version")).toBe("2023-06-01");
20+
expect(headers.get("x-api-key")).toBe("sk-umans-valid");
21+
expect(headers.get("authorization")).toBeNull();
22+
expect(body.model).toBe("umans-coder");
23+
expect(body.max_tokens).toBe(1);
24+
25+
return new Response(JSON.stringify({ id: "msg_test", type: "message" }), {
26+
status: 200,
27+
headers: { "Content-Type": "application/json" },
28+
});
29+
});
30+
31+
const apiKey = await loginUmans({
32+
onAuth: info => {
33+
authUrl = info.url;
34+
authInstructions = info.instructions;
35+
},
36+
onPrompt: async prompt => {
37+
promptMessage = prompt.message;
38+
promptPlaceholder = prompt.placeholder;
39+
return " sk-umans-valid ";
40+
},
41+
fetch: fetchMock,
42+
});
43+
44+
expect(apiKey).toBe("sk-umans-valid");
45+
expect(authUrl).toBe("https://app.umans.ai/billing");
46+
expect(authInstructions).toContain("Dashboard → API Keys");
47+
expect(promptMessage).toBe("Paste your Umans API key");
48+
expect(promptPlaceholder).toBe("sk-...");
49+
expect(fetchMock).toHaveBeenCalledTimes(1);
50+
});
51+
52+
it("surfaces validation errors from the Anthropic messages endpoint", async () => {
53+
const fetchMock: FetchImpl = vi.fn(
54+
async () =>
55+
new Response("invalid key", {
56+
status: 401,
57+
headers: { "Content-Type": "text/plain" },
58+
}),
59+
);
60+
61+
await expect(
62+
loginUmans({
63+
onPrompt: async () => "sk-umans-bad",
64+
fetch: fetchMock,
65+
}),
66+
).rejects.toThrow("Umans AI Coding Plan API key validation failed (401): invalid key");
67+
expect(fetchMock).toHaveBeenCalledTimes(1);
68+
});
69+
});

0 commit comments

Comments
 (0)