Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/bots-pick-better-models.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"akeru-bot": minor
---

Add backup accounts to a provider, named by plan, and bots move to the next one when an account hits a usage limit. New bots start on Claude Opus 5.5 for Claude, Grok 4.7 for Grok, and GPT-6 Luna for OpenCode Go. The model picker moved from the chat composer into the bot's settings, chats show a sign-in card when a bot's provider needs connecting, and the macOS desktop sidebar no longer crowds the window controls.
313 changes: 228 additions & 85 deletions apps/mobile/src/features/settings/ProviderConnections.tsx

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion apps/mobile/src/lib/modelOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
SubscriptionProviderStatus,
} from "@akeru/contracts";
import { PROVIDER_DISPLAY_NAMES, ProviderDriverKind } from "@akeru/contracts";
import { formatModelSlug } from "@akeru/shared/model";
import {
filterProvidersBySubscriptionConnection,
withRefreshableSubscriptionLogin,
Expand Down Expand Up @@ -90,7 +91,7 @@ export function resolveModelSendBlock(

const modelName =
provider?.models.find((candidate) => candidate.slug === selection.model)?.name ??
selection.model;
formatModelSlug(selection.model);

return presentProviderUnavailability(
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export function mastraWireModelFor(provider: IntegrationProvider): string {
// its concrete model before reaching Mastra.
if (provider === CLAUDE_AGENT_PROVIDER) return "anthropic/claude-opus-4-6[1m]";

if (provider === GROK_PROVIDER) return "xai/grok-4.6";
if (provider === GROK_PROVIDER) return "xai/grok-4.7";

return `opencode-go/${modelFor(provider, "b")}`;
}
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.subscriptionAuthCancel]: AuthOrchestrationOperateScope,
[WS_METHODS.subscriptionAuthLogout]: AuthOrchestrationOperateScope,
[WS_METHODS.subscriptionAuthHealthTest]: AuthOrchestrationOperateScope,
[WS_METHODS.subscriptionAuthSetAccountOrder]: AuthOrchestrationOperateScope,
[WS_METHODS.imageProviderList]: AuthOrchestrationReadScope,
[WS_METHODS.imageProviderHealthTest]: AuthOrchestrationOperateScope,
[WS_METHODS.mcpServerAuthenticate]: AuthOrchestrationOperateScope,
Expand Down
236 changes: 236 additions & 0 deletions apps/server/src/provider/AkeruMastraHarness.accounts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import type { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { AuthStorage } from "@mastra/code-sdk/auth/storage";
import * as NodeFS from "node:fs";
import { expect, it, vi } from "vite-plus/test";

import { resolveAkeruMastraModel } from "./AkeruMastraHarness.ts";
import { makeTestSubscriptionAuthService } from "../subscription-auth/testUtils/subscriptionAuthService.ts";
import { fixture } from "../subscription-auth/testUtils/subscriptionAuthStorage.ts";

it("pins OpenCode Go's key, endpoint, and outcome when another thread benches its account", async () => {
const { authPath, directory } = fixture();
NodeFS.writeFileSync(
authPath,
JSON.stringify({
"opencode-go": { type: "api-key", access: "first-key", baseUrl: "https://first.example/v1" },
"account:opencode-go:backup": {
type: "api-key",
access: "second-key",
baseUrl: "https://second.example/v1",
},
}),
);
const service = await makeTestSubscriptionAuthService(authPath);

const request = vi.fn(async (_input: string | URL | Request, _init?: RequestInit) =>
Response.json({
id: "reply",
model: "deepseek-v4-pro",
choices: [
{ index: 0, message: { role: "assistant", content: "Done" }, finish_reason: "stop" },
],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
}),
);

vi.stubGlobal("fetch", request);

try {
const model = resolveAkeruMastraModel(
"opencode-go/deepseek-v4-pro",
new AuthStorage(authPath),
undefined,
async (instanceId, threadId) => {
const credential = service.getApiKeyCredential("opencode-go", instanceId, threadId);
service.recordRequestFailure(
"opencode-go",
"Spending limit reached.",
undefined,
"request",
"thread-other",
);
expect(service.getApiKeyCredential("opencode-go", undefined, "thread-backup")?.access).toBe(
"second-key",
);

return credential;
},
undefined,
(provider, instanceId, threadId) =>
service.getApiKeyCredential(provider, instanceId, threadId),
undefined,
undefined,
undefined,
"thread-request",
) as ReturnType<ReturnType<typeof createOpenAICompatible>>;

await model.doGenerate({
prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
});
expect(request.mock.calls[0]?.[0]).toBe("https://first.example/v1/chat/completions");
expect(new Headers(request.mock.calls[0]?.[1]?.headers).get("authorization")).toBe(
"Bearer first-key",
);
service.recordRequestSuccess("opencode-go", undefined, "thread-request");
expect(
service
.linkedAccountStatuses()
.find((status) => status.provider === "opencode-go" && status.accountId === "default")
?.health,
).toBe("recovered");
} finally {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
NodeFS.rmSync(directory, { recursive: true, force: true });
}
});

it.each(["openai-codex", "anthropic", "xai"] as const)(
"pins %s OAuth auth when a backup uses an API key",
async (provider) => {
const { authPath, directory } = fixture();
NodeFS.writeFileSync(
authPath,
JSON.stringify({
[provider]: {
type: "oauth",
access: "expired",
refresh: "main-refresh",
expires: 0,
accountId: "main-account",
},
[`account:${provider}:backup`]: { type: "api-key", access: "backup-key" },
}),
);
const service = await makeTestSubscriptionAuthService(authPath);
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("VITEST", undefined);

const request = vi.fn(async (input: string | URL | Request, _init?: RequestInit) => {
if (String(input).includes("token"))
return Response.json({
access_token: "refreshed-main",
refresh_token: "main-refresh",
expires_in: 3600,
});

return new Response("dispatch reached", { status: 503 });
});

vi.stubGlobal("fetch", request);

try {
const prefix = { "openai-codex": "openai", anthropic: "anthropic", xai: "xai" }[provider];

const model = resolveAkeruMastraModel(
`${prefix}/test-model`,
new AuthStorage(authPath),
undefined,
undefined,
undefined,
(id, scope, thread) => service.getApiKeyCredential(id, scope, thread),
undefined,
(id, scope, thread) => service.getOAuthCredential(id, scope, thread),
(id, scope, thread) => service.getAccessToken(id, scope, thread),
"thread-request",
) as ReturnType<ReturnType<typeof createOpenAICompatible>>;

service.recordRequestFailure(
provider,
"Spending limit reached.",
undefined,
"request",
"thread-other",
);
expect(service.getApiKeyCredential(provider, undefined, "thread-backup")?.access).toBe(
"backup-key",
);
await expect(
model.doGenerate({
prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
}),
).rejects.toThrow();
const dispatch = request.mock.calls.find(([input]) => !String(input).includes("token"));
expect(dispatch).toBeDefined();
const headers = new Headers(dispatch?.[1]?.headers);
expect(headers.get("authorization")).toBe("Bearer refreshed-main");

if (provider === "openai-codex")
expect(headers.get("chatgpt-account-id")).toBe("main-account");
service.recordRequestSuccess(provider, undefined, "thread-request");
expect(
service
.linkedAccountStatuses()
.find((status) => status.provider === provider && status.accountId === "default")?.health,
).toBe("recovered");
} finally {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
NodeFS.rmSync(directory, { recursive: true, force: true });
}
},
);

it("pins Codex Responses auth when another thread selects an OAuth backup", async () => {
const { authPath, directory } = fixture();
NodeFS.writeFileSync(
authPath,
JSON.stringify({
"openai-codex": { type: "api-key", access: "main-key", baseUrl: "https://main.example/v1" },
"account:openai-codex:backup": {
type: "oauth",
access: "backup-token",
refresh: "backup-refresh",
expires: Date.now() + 60000,
accountId: "backup-account",
},
}),
);
const service = await makeTestSubscriptionAuthService(authPath);
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("VITEST", undefined);

const request = vi.fn(
async (_input: string | URL | Request, _init?: RequestInit) =>
new Response("dispatch reached", { status: 503 }),
);

vi.stubGlobal("fetch", request);

try {
const model = resolveAkeruMastraModel(
"openai/test-model",
new AuthStorage(authPath),
undefined,
undefined,
undefined,
(id, scope, thread) => service.getApiKeyCredential(id, scope, thread),
undefined,
undefined,
undefined,
"thread-request",
) as ReturnType<ReturnType<typeof createOpenAICompatible>>;

service.recordRequestFailure(
"openai-codex",
"Spending limit reached.",
undefined,
"request",
"thread-other",
);
expect(service.getOAuthCredential("openai-codex", undefined, "thread-backup")?.access).toBe(
"backup-token",
);
await expect(
model.doGenerate({ prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }] }),
).rejects.toThrow();
expect(request.mock.calls[0]?.[0]).toBe("https://main.example/v1/responses");
expect(new Headers(request.mock.calls[0]?.[1]?.headers).get("authorization")).toBe(
"Bearer main-key",
);
} finally {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
NodeFS.rmSync(directory, { recursive: true, force: true });
}
});
15 changes: 6 additions & 9 deletions apps/server/src/provider/AkeruMastraHarness.models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ describe("AkeruMastraHarness", () => {
getCredential,
),
).toMatchObject({ modelId: "gpt-5.6", provider: "openai.responses" });
expect(getCredential).toHaveBeenCalledWith("openai-codex");
expect(getCredential).toHaveBeenCalledWith("openai-codex", undefined, undefined);
});

it("uses the selected instance key without reading the provider-wide credential", () => {
Expand Down Expand Up @@ -95,7 +95,7 @@ describe("AkeruMastraHarness", () => {
},
),
).toMatchObject({ modelId: "grok-code-fast-1", provider: "xai.chat" });
expect(getCredential).toHaveBeenCalledWith("xai", "grok_work");
expect(getCredential).toHaveBeenCalledWith("xai", "grok_work", undefined);
});

it("does not leak provider-wide credentials into an isolated instance", () => {
Expand Down Expand Up @@ -233,12 +233,9 @@ describe("AkeruMastraHarness", () => {
"opencode-go/gpt-5.6-luna",
);
assert.deepInclude(
resolveAkeruMastraModel(
"opencode-go/gpt-5.6-luna",
authStorage,
undefined,
async () => "go-key",
),
resolveAkeruMastraModel("opencode-go/gpt-5.6-luna", authStorage, undefined, async () => ({
access: "go-key",
})),
{ provider: "opencode-go.responses", modelId: "gpt-5.6-luna" },
);
assert.throws(
Expand Down Expand Up @@ -338,7 +335,7 @@ describe("AkeruMastraHarness", () => {
{ provider: "anthropic.messages", modelId: "claude-sonnet-4-5" },
);
assert.equal(mastraModelId(ProviderDriverKind.make("grok"), "grok-4"), "xai/grok-4");
assert.equal(mastraModelId(ProviderDriverKind.make("grok"), "grok-build"), "xai/grok-4.6");
assert.equal(mastraModelId(ProviderDriverKind.make("grok"), "grok-build"), "xai/grok-4.7");
assert.deepInclude(
resolveAkeruMastraModel(
"xai/grok-4",
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/provider/AkeruMastraHarness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
controllerModelId,
controllerModelOptions,
controllerModelConnection,
controllerResourceId,
} from "./mastra/AkeruMemory.ts";
import {
AkeruMastraHarnessError,
Expand Down Expand Up @@ -232,6 +233,7 @@ export const makeAkeruMastraHarness = Effect.fnUntraced(function* (
controllerModelConnection(requestContext, options.getModelConnection),
options.getSubscriptionOAuth,
options.getSubscriptionAccessToken,
controllerResourceId(requestContext),
),
tools: ({ requestContext }) => resolveAkeruTools(requestContext, options),
memory: observationalMemory.memory,
Expand Down
9 changes: 5 additions & 4 deletions apps/server/src/provider/AkeruOpenCodeGoProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,8 @@ describe("AkeruOpenCodeGoProvider", () => {

await buildAkeruOpenCodeGoFetch(
protocol,
async () => "custom-key",
async () => ({ access: "custom-key", baseUrl: "http://localhost:8080/v1" }),
request,
() => "http://localhost:8080/v1",
)("https://opencode.ai/zen/go/v1/messages", { method: "POST", body: "{}" });
expect(request).toHaveBeenCalledWith(
"http://localhost:8080/v1/messages",
Expand All @@ -40,7 +39,9 @@ describe("AkeruOpenCodeGoProvider", () => {
});

it("builds a model while keeping the OpenCode Go model id", () => {
expect(akeruOpenCodeGoProvider("gpt-5.6-luna", async () => "go-key")).toMatchObject({
expect(
akeruOpenCodeGoProvider("gpt-5.6-luna", async () => ({ access: "go-key" })),
).toMatchObject({
modelId: "gpt-5.6-luna",
});
});
Expand All @@ -58,7 +59,7 @@ describe("AkeruOpenCodeGoProvider", () => {

await buildAkeruOpenCodeGoFetch(
"chat-completions",
async () => "go-key",
async () => ({ access: "go-key" }),
request,
)("https://opencode.ai/zen/go/v1/chat/completions", {
method: "POST",
Expand Down
Loading
Loading