Skip to content

Commit 6b1c9b2

Browse files
committed
feat: add Umans provider
1 parent 47ee096 commit 6b1c9b2

31 files changed

Lines changed: 776 additions & 0 deletions

packages/types/src/__tests__/index.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ describe("GLOBAL_STATE_KEYS", () => {
1515
expect(GLOBAL_STATE_KEYS).not.toContain("openRouterApiKey")
1616
})
1717

18+
it("should not contain Umans API key (secret)", () => {
19+
expect(GLOBAL_STATE_KEYS).not.toContain("umansApiKey")
20+
})
21+
1822
it("should contain OpenAI Compatible base URL setting", () => {
1923
expect(GLOBAL_STATE_KEYS).toContain("codebaseIndexOpenAiCompatibleBaseUrl")
2024
})

packages/types/src/global-settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ export type RooCodeSettings = GlobalSettings & ProviderSettings
290290
export const SECRET_STATE_KEYS = [
291291
"apiKey",
292292
"openRouterApiKey",
293+
"umansApiKey",
293294
"awsAccessKey",
294295
"awsApiKey",
295296
"awsSecretKey",

packages/types/src/provider-settings.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export const DEFAULT_CONSECUTIVE_MISTAKE_LIMIT = 3
3838

3939
export const dynamicProviders = [
4040
"openrouter",
41+
"umans",
4142
"vercel-ai-gateway",
4243
"zoo-gateway",
4344
"litellm",
@@ -221,6 +222,11 @@ const openRouterSchema = baseProviderSettingsSchema.extend({
221222
openRouterSpecificProvider: z.string().optional(),
222223
})
223224

225+
const umansSchema = baseProviderSettingsSchema.extend({
226+
umansApiKey: z.string().optional(),
227+
umansModelId: z.string().optional(),
228+
})
229+
224230
const bedrockSchema = apiModelIdProviderModelSchema.extend({
225231
awsAccessKey: z.string().optional(),
226232
awsSecretKey: z.string().optional(),
@@ -433,6 +439,7 @@ const defaultSchema = z.object({
433439
export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProvider", [
434440
anthropicSchema.merge(z.object({ apiProvider: z.literal("anthropic") })),
435441
openRouterSchema.merge(z.object({ apiProvider: z.literal("openrouter") })),
442+
umansSchema.merge(z.object({ apiProvider: z.literal("umans") })),
436443
bedrockSchema.merge(z.object({ apiProvider: z.literal("bedrock") })),
437444
vertexSchema.merge(z.object({ apiProvider: z.literal("vertex") })),
438445
openAiSchema.merge(z.object({ apiProvider: z.literal("openai") })),
@@ -470,6 +477,7 @@ export const providerSettingsSchema = z.object({
470477
apiProvider: providerNamesWithRetiredSchema.optional(),
471478
...anthropicSchema.shape,
472479
...openRouterSchema.shape,
480+
...umansSchema.shape,
473481
...bedrockSchema.shape,
474482
...vertexSchema.shape,
475483
...openAiSchema.shape,
@@ -522,6 +530,7 @@ export const PROVIDER_SETTINGS_KEYS = providerSettingsSchema.keyof().options
522530
export const modelIdKeys = [
523531
"apiModelId",
524532
"openRouterModelId",
533+
"umansModelId",
525534
"openAiModelId",
526535
"anthropicCustomModelId",
527536
"ollamaModelId",
@@ -554,6 +563,7 @@ export const isTypicalProvider = (key: unknown): key is TypicalProvider =>
554563
export const modelIdKeysByProvider: Record<TypicalProvider, ModelIdKey> = {
555564
anthropic: "apiModelId",
556565
openrouter: "openRouterModelId",
566+
umans: "umansModelId",
557567
bedrock: "apiModelId",
558568
vertex: "apiModelId",
559569
"openai-codex": "apiModelId",
@@ -709,6 +719,7 @@ export const MODELS_BY_PROVIDER: Record<
709719
poe: { id: "poe", label: "Poe", models: [] },
710720
litellm: { id: "litellm", label: "LiteLLM", models: [] },
711721
openrouter: { id: "openrouter", label: "OpenRouter", models: [] },
722+
umans: { id: "umans", label: "Umans", models: [] },
712723
requesty: { id: "requesty", label: "Requesty", models: [] },
713724
unbound: { id: "unbound", label: "Unbound", models: [] },
714725
"vercel-ai-gateway": { id: "vercel-ai-gateway", label: "Vercel AI Gateway", models: [] },

packages/types/src/providers/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export * from "./qwen-code.js"
1818
export * from "./requesty.js"
1919
export * from "./sambanova.js"
2020
export * from "./unbound.js"
21+
export * from "./umans.js"
2122
export * from "./vertex.js"
2223
export * from "./vscode-llm.js"
2324
export * from "./xai.js"
@@ -44,6 +45,7 @@ import { qwenCodeDefaultModelId } from "./qwen-code.js"
4445
import { requestyDefaultModelId } from "./requesty.js"
4546
import { sambaNovaDefaultModelId } from "./sambanova.js"
4647
import { unboundDefaultModelId } from "./unbound.js"
48+
import { umansDefaultModelId } from "./umans.js"
4749
import { vertexDefaultModelId } from "./vertex.js"
4850
import { vscodeLlmDefaultModelId } from "./vscode-llm.js"
4951
import { xaiDefaultModelId } from "./xai.js"
@@ -71,6 +73,8 @@ export function getProviderDefaultModelId(
7173
return openRouterDefaultModelId
7274
case "requesty":
7375
return requestyDefaultModelId
76+
case "umans":
77+
return umansDefaultModelId
7478
case "litellm":
7579
return litellmDefaultModelId
7680
case "xai":
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { ModelInfo } from "../model.js"
2+
3+
export const UMANS_DEFAULT_BASE_URL = "https://api.code.umans.ai/v1"
4+
5+
// Umans
6+
// https://api.code.umans.ai/v1/models/info
7+
export const umansDefaultModelId = "umans-coder"
8+
9+
export const umansDefaultModelInfo: ModelInfo = {
10+
maxTokens: 32_768,
11+
contextWindow: 262_144,
12+
supportsImages: true,
13+
supportsPromptCache: false,
14+
supportsMaxTokens: true,
15+
inputPrice: 0.95,
16+
outputPrice: 4.0,
17+
description: "Umans Coder is Umans' recommended model for complex, coding-heavy workloads and coding agents.",
18+
}

src/api/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
VsCodeLmHandler,
2525
RequestyHandler,
2626
UnboundHandler,
27+
UmansHandler,
2728
FakeAIHandler,
2829
XAIHandler,
2930
LiteLLMHandler,
@@ -137,6 +138,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
137138
return new AnthropicHandler(options)
138139
case "openrouter":
139140
return new OpenRouterHandler(options)
141+
case "umans":
142+
return new UmansHandler(options)
140143
case "bedrock":
141144
return new AwsBedrockHandler(options)
142145
case "vertex":
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// npx vitest run api/providers/__tests__/umans.spec.ts
2+
3+
vitest.mock("../utils/timeout-config", () => ({
4+
getApiRequestTimeout: vitest.fn().mockReturnValue(300_000),
5+
}))
6+
7+
const MOCK_TIMEOUT_MS = 300_000
8+
9+
import { Anthropic } from "@anthropic-ai/sdk"
10+
import OpenAI from "openai"
11+
12+
import { UmansHandler } from "../umans"
13+
import type { ApiHandlerOptions } from "../../../shared/api"
14+
import { Package } from "../../../shared/package"
15+
16+
const mockCreate = vitest.fn()
17+
18+
vitest.mock("openai", () => ({
19+
default: vitest.fn().mockImplementation(function () {
20+
return {
21+
chat: {
22+
completions: {
23+
create: mockCreate,
24+
},
25+
},
26+
}
27+
}),
28+
}))
29+
30+
vitest.mock("../fetchers/modelCache", () => ({
31+
getModels: vitest.fn().mockResolvedValue({
32+
"umans-coder": {
33+
maxTokens: 32768,
34+
contextWindow: 262144,
35+
supportsImages: true,
36+
supportsPromptCache: false,
37+
supportsMaxTokens: true,
38+
inputPrice: 0.95,
39+
outputPrice: 4,
40+
description: "Umans Coder",
41+
},
42+
"umans-glm-5.2": {
43+
maxTokens: 131071,
44+
contextWindow: 405504,
45+
supportsImages: true,
46+
supportsPromptCache: false,
47+
supportsMaxTokens: true,
48+
supportsReasoningEffort: ["none", "high", "max"],
49+
reasoningEffort: "high",
50+
inputPrice: 1.4,
51+
outputPrice: 4.4,
52+
description: "Umans GLM 5.2",
53+
},
54+
}),
55+
}))
56+
57+
describe("UmansHandler", () => {
58+
const mockOptions: ApiHandlerOptions = {
59+
umansApiKey: "test-key",
60+
umansModelId: "umans-coder",
61+
}
62+
63+
beforeEach(() => vitest.clearAllMocks())
64+
65+
it("initializes with the Umans base URL and API key", () => {
66+
new UmansHandler(mockOptions)
67+
68+
expect(OpenAI).toHaveBeenCalledWith({
69+
baseURL: "https://api.code.umans.ai/v1",
70+
apiKey: "test-key",
71+
defaultHeaders: {
72+
"HTTP-Referer": "https://github.com/Zoo-Code-Org/Zoo-Code",
73+
"X-Title": "Zoo Code",
74+
"User-Agent": `ZooCode/${Package.version}`,
75+
},
76+
timeout: MOCK_TIMEOUT_MS,
77+
})
78+
})
79+
80+
it("returns the default model when no options are provided", async () => {
81+
const handler = new UmansHandler({})
82+
const result = await handler.fetchModel()
83+
84+
expect(result.id).toBe("umans-coder")
85+
expect(result.info.description).toBe("Umans Coder")
86+
})
87+
88+
it("uses the provider's default OpenAI reasoning payload for Umans GLM models", async () => {
89+
const handler = new UmansHandler({
90+
umansApiKey: "test-key",
91+
umansModelId: "umans-glm-5.2",
92+
reasoningEffort: "max",
93+
})
94+
95+
const mockStream = {
96+
async *[Symbol.asyncIterator]() {
97+
yield {
98+
choices: [{ delta: { content: "done" } }],
99+
}
100+
},
101+
}
102+
103+
mockCreate.mockResolvedValue(mockStream)
104+
105+
const generator = handler.createMessage("system prompt", [{ role: "user" as const, content: "test" }])
106+
await generator.next()
107+
108+
expect(mockCreate).toHaveBeenCalledWith(
109+
expect.objectContaining({
110+
model: "umans-glm-5.2",
111+
reasoning_effort: "max",
112+
stream: true,
113+
}),
114+
)
115+
})
116+
})

src/api/providers/fetchers/__tests__/modelCache.spec.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ vi.mock("fs", () => ({
4343
vi.mock("../litellm")
4444
vi.mock("../openrouter")
4545
vi.mock("../requesty")
46+
vi.mock("../umans")
4647

4748
// Mock ContextProxy with a simple static instance
4849
vi.mock("../../../core/config/ContextProxy", () => ({
@@ -63,10 +64,12 @@ import { getModels, getModelsFromCache } from "../modelCache"
6364
import { getLiteLLMModels } from "../litellm"
6465
import { getOpenRouterModels } from "../openrouter"
6566
import { getRequestyModels } from "../requesty"
67+
import { getUmansModels } from "../umans"
6668

6769
const mockGetLiteLLMModels = getLiteLLMModels as Mock<typeof getLiteLLMModels>
6870
const mockGetOpenRouterModels = getOpenRouterModels as Mock<typeof getOpenRouterModels>
6971
const mockGetRequestyModels = getRequestyModels as Mock<typeof getRequestyModels>
72+
const mockGetUmansModels = getUmansModels as Mock<typeof getUmansModels>
7073

7174
const DUMMY_REQUESTY_KEY = "requesty-key-for-testing"
7275

@@ -130,6 +133,23 @@ describe("getModels with new GetModelsOptions", () => {
130133
expect(result).toEqual(mockModels)
131134
})
132135

136+
it("calls getUmansModels for umans provider", async () => {
137+
const mockModels = {
138+
"umans-coder": {
139+
maxTokens: 32768,
140+
contextWindow: 262144,
141+
supportsPromptCache: false,
142+
description: "Umans Coder",
143+
},
144+
}
145+
mockGetUmansModels.mockResolvedValue(mockModels)
146+
147+
const result = await getModels({ provider: "umans" })
148+
149+
expect(mockGetUmansModels).toHaveBeenCalled()
150+
expect(result).toEqual(mockModels)
151+
})
152+
133153
it("handles errors and re-throws them", async () => {
134154
const expectedError = new Error("LiteLLM connection failed")
135155
mockGetLiteLLMModels.mockRejectedValue(expectedError)
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// npx vitest run api/providers/fetchers/__tests__/umans.spec.ts
2+
3+
import axios from "axios"
4+
5+
import { getUmansModels } from "../umans"
6+
7+
vi.mock("axios")
8+
const mockAxiosGet = vi.mocked(axios.get)
9+
10+
describe("getUmansModels", () => {
11+
it("parses Umans model metadata and pricing", async () => {
12+
mockAxiosGet
13+
.mockResolvedValueOnce({
14+
data: {
15+
"umans-flash": {
16+
name: "umans-flash",
17+
display_name: "Umans Flash",
18+
description: "Fast coding model",
19+
capabilities: {
20+
max_completion_tokens: 262144,
21+
recommended_max_tokens: 32768,
22+
context_window: 262144,
23+
supports_vision: true,
24+
reasoning: {
25+
supported: true,
26+
can_disable: true,
27+
levels: ["none", "low", "medium", "high"],
28+
default_level: "medium",
29+
},
30+
},
31+
},
32+
},
33+
})
34+
.mockResolvedValueOnce({
35+
data: {
36+
data: [
37+
{
38+
id: "umans-flash",
39+
pricing: { input: 0.15, output: 1.0 },
40+
},
41+
],
42+
},
43+
})
44+
45+
const models = await getUmansModels()
46+
47+
expect(mockAxiosGet).toHaveBeenNthCalledWith(1, "https://api.code.umans.ai/v1/models/info")
48+
expect(mockAxiosGet).toHaveBeenNthCalledWith(2, "https://api.code.umans.ai/v1/models")
49+
expect(models["umans-flash"]).toEqual({
50+
maxTokens: 32768,
51+
contextWindow: 262144,
52+
supportsImages: true,
53+
supportsPromptCache: false,
54+
supportsMaxTokens: true,
55+
supportsReasoningEffort: ["none", "low", "medium", "high"],
56+
reasoningEffort: "medium",
57+
inputPrice: 0.15,
58+
outputPrice: 1,
59+
description: "Fast coding model",
60+
})
61+
})
62+
})

src/api/providers/fetchers/modelCache.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type { RouterName } from "../../../shared/api"
1717
import { fileExistsAtPath } from "../../../utils/fs"
1818

1919
import { getOpenRouterModels } from "./openrouter"
20+
import { getUmansModels } from "./umans"
2021
import { getVercelAiGatewayModels } from "./vercel-ai-gateway"
2122
import { getOpencodeGoModels } from "./opencode-go"
2223
import { getRequestyModels } from "./requesty"
@@ -78,6 +79,9 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise<Model
7879
case "openrouter":
7980
models = await getOpenRouterModels()
8081
break
82+
case "umans":
83+
models = await getUmansModels()
84+
break
8185
case "requesty":
8286
// Requesty models endpoint requires an API key for per-user custom policies.
8387
models = await getRequestyModels(options.baseUrl, options.apiKey)
@@ -268,6 +272,7 @@ export async function initializeModelCacheRefresh(): Promise<void> {
268272
// Providers that work without API keys
269273
const publicProviders: Array<{ provider: RouterName; options: GetModelsOptions }> = [
270274
{ provider: "openrouter", options: { provider: "openrouter" } },
275+
{ provider: "umans", options: { provider: "umans" } },
271276
{ provider: "vercel-ai-gateway", options: { provider: "vercel-ai-gateway" } },
272277
]
273278

0 commit comments

Comments
 (0)