Skip to content
Open
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
85 changes: 80 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,22 +129,29 @@ For the complete first-session walkthrough, see the
Backboard SSO into a separate application, see the
[Backboard SSO integration guide](https://docs.backboard.io/concepts/sso).

## Use your own model-provider keys
## Use your own model providers

A Backboard login is not required when you want to call a supported provider
A Backboard login is not required when you want to call a model provider
directly. On the authentication screen choose **Bring your own key**, or run:

```text
/keys
/providers
```

R-CLI currently supports direct keys for:
`/keys` remains an alias for `/providers`.

R-CLI includes first-class direct integrations for:

- Anthropic
- OpenAI
- Google
- OpenRouter

You can also add custom OpenAI Chat Completions, OpenAI Responses, and
Anthropic Messages compatible endpoints. This covers most hosted gateways and
local servers, including Ollama, LM Studio, vLLM, Together, Fireworks, Groq,
and similar OpenAI-compatible services.

Provider keys are validated before saving, encrypted at rest in
`~/.backboard/keys.json`, and never written to project session logs. Add keys
through the interactive flow rather than placing provider secrets in project
Expand All @@ -154,6 +161,74 @@ You can keep both a Backboard login and provider keys. When the same provider
is available through both, the enabled direct key takes precedence for that
provider's models.

### Custom model providers

Use `/providers`, choose **Add custom provider**, and enter:

- A stable provider ID and display name
- The API protocol
- A base URL
- An encrypted API key, environment variable, or no authentication
- Optional model discovery endpoint, manual models, headers, and request
arguments

For an OpenAI-compatible local endpoint:

```text
Name: Local Provider
ID: local-provider
Protocol: OpenAI Chat Completions
Base URL: http://localhost:8000/v1
Authentication: No authentication
Models endpoint: models
```

The connection is tested before saving. Discovered models then appear under
the provider's own tab in `/model`. The provider manager also supports editing,
re-testing, enabling/disabling, and removal.

Advanced users can edit the non-secret definitions in
`~/.backboard/config.json`:

```json
{
"providers": [
{
"id": "local-provider",
"name": "Local Provider",
"protocol": "openai-responses",
"baseUrl": "http://localhost:8000/v1",
"auth": { "type": "none" },
"headers": {
"X-Workspace": "${WORKSPACE_ID}"
},
"extraArgs": {
"temperature": 0.2
},
"models": [
{
"id": "gpt-5.6-sol",
"contextLimit": 400000,
"maxOutputTokens": 32768,
"supportsThinking": true
}
]
}
]
}
```

Set `"discoverModels": false` for an endpoint without `GET /models`; manual
models are then required. `modelsPath` may be a relative path or full URL.
String values in `baseUrl`, `modelsPath`, `headers`, `extraArgs`, and
model-level `extraArgs` support `${ENV_VAR}` references. Missing variables fail
clearly without sending the literal placeholder.

This compatibility layer targets OpenAI-compatible and Anthropic-compatible
HTTP APIs. Provider-native authentication such as AWS SigV4, cloud SDK
credential chains, and provider-specific OAuth requires a dedicated
integration.

## Start your first session

Run `backboard` from the project you want it to work on:
Expand Down Expand Up @@ -288,7 +363,7 @@ Type `/help` inside R-CLI for the authoritative command list.
| --------------------------- | ------------------------------------------ |
| `/model` | Choose a model and thinking mode |
| `/settings` | Adjust session preferences |
| `/keys` | Manage direct provider API keys |
| `/providers`, `/keys` | Manage model providers and credentials |
| `/sessions`, `/session` | Browse Backboard and local BYOK sessions |
| `/resume SESSION_ID` | Resume a session directly by ID |
| `/context` | Inspect context-window usage |
Expand Down
42 changes: 27 additions & 15 deletions scripts/verify-tool-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@
* Opt-in; never part of `bun test`. Costs a few tokens per model.
*/
import { Config } from "../src/config/Config.ts";
import { BYOK_PROVIDER_IDS } from "../src/core/keys/ProviderKeyTypes.ts";
import { ToolRegistry } from "../src/core/tools/ToolRegistry.ts";
import { BackboardClient } from "../src/providers/backboard/BackboardClient.ts";
import type { ModelCatalogItem } from "../src/providers/backboard/types.ts";
import { byokAdapter } from "../src/providers/byok/registry.ts";
import { createAgentClient } from "../src/providers/createAgentClient.ts";
import { createDefaultTools } from "../src/tools/index.ts";

Expand All @@ -30,17 +30,21 @@ config.enableComputerUse();
config.enableBrowserUse();
const router = createAgentClient(config);
await Promise.all(
config.auth.providerKeys.map(async ({ provider, key }) => {
try {
await byokAdapter(provider).listModels(key);
} catch (err) {
throw new Error(
`${provider} catalog failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}),
config.auth.providerKeys
.filter(({ provider }) => !filter || provider.includes(filter))
.map(async ({ provider, key }) => {
try {
const adapter = config.providerRegistry.get(provider);
if (!adapter) throw new Error("provider adapter is unavailable");
await adapter.listModels(key);
} catch (err) {
throw new Error(
`${provider} catalog failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}),
);
let toolList: ReturnType<typeof createDefaultTools> = [];
toolList = createDefaultTools({
Expand Down Expand Up @@ -71,7 +75,12 @@ const PREFERRED = [
];

function pick(models: ModelCatalogItem[]): string | undefined {
const names = models.map((m) => m.name).filter((n) => !SKIP.test(n));
const names = models
.filter(
(model) => !filter || `${model.provider}/${model.name}`.includes(filter),
)
.map((m) => m.name)
.filter((n) => !SKIP.test(n));
for (const re of PREFERRED) {
const hit = names.find((n) => re.test(n));
if (hit) return hit;
Expand Down Expand Up @@ -164,8 +173,11 @@ if (backboard) {
const server = catalog.filter((m) => m.source !== "byok");
const providers = new Set(server.map((m) => m.provider));
// The merged catalog hides Backboard's own route for providers you also hold a key for.
for (const provider of [...new Set(byok.map((m) => m.provider))])
providers.add(provider);
for (const provider of BYOK_PROVIDER_IDS) {
if (byok.some((model) => model.provider === provider)) {
providers.add(provider);
}
}
for (const provider of providers) {
if (SKIP.test(provider)) continue;
const model =
Expand Down
3 changes: 3 additions & 0 deletions src/config/BackboardConfigTypes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { JsonObject } from "../utils/JsonTypes.ts";
import type { MemoryMode, MemoryProfile, ThinkingIntent } from "./defaults.ts";
import type { CustomProviderDefinition } from "./providers.ts";

export interface BackboardConfigFile {
apiKey?: string;
Expand All @@ -13,6 +14,8 @@ export interface BackboardConfigFile {
memoryProfile?: MemoryProfile;
notify?: boolean;
verbose?: boolean;
/** User-defined HTTP model providers. Secrets remain in keys.json. */
providers?: CustomProviderDefinition[];
/** Expert mode: implementation runs on `model`, planning stays on `/model`. */
expert?: ExpertConfig;
}
Expand Down
20 changes: 14 additions & 6 deletions src/config/Config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { canonicalToolName } from "../core/tools/names.ts";
import { ToolPolicy } from "../core/tools/ToolPolicy.ts";
import type { ProviderRegistry } from "../providers/byok/registry.ts";
import {
type AuthState,
hasAnyCredentials,
Expand Down Expand Up @@ -473,19 +474,26 @@ export class Config {
return this.auth.providerKeys.length > 0;
}

get providerRegistry(): ProviderRegistry {
return this.auth.providerRegistry;
}

hasProviderKeyFor(provider: string): boolean {
return this.auth.providerKeys.some((entry) => entry.provider === provider);
const normalized = provider.trim().toLowerCase();
return this.auth.providerKeys.some(
(entry) => entry.provider.trim().toLowerCase() === normalized,
);
}

private hasBackendFor(model: ModelRef): boolean {
return this.hasBackboardAuth || this.hasProviderKeyFor(model.provider);
const hasDirectProvider = this.hasProviderKeyFor(model.provider);
return this.providerRegistry.definition(model.provider)
? hasDirectProvider
: this.hasBackboardAuth || hasDirectProvider;
}

get hasBackendForCurrentModel(): boolean {
return (
this.hasBackboardAuth ||
this.hasProviderKeyFor(this.currentModel.provider)
);
return this.hasBackendFor(this.currentModel);
}

/**
Expand Down
29 changes: 16 additions & 13 deletions src/config/auth.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
import {
enabledProviderKeys,
readProviderKeys,
} from "../core/keys/ProviderKeyStore.ts";
import type {
ByokProviderId,
ResolvedProviderKey,
} from "../core/keys/ProviderKeyTypes.ts";
import { readProviderKeys } from "../core/keys/ProviderKeyStore.ts";
import type { ResolvedProviderKey } from "../core/keys/ProviderKeyTypes.ts";
import { ProviderRegistry } from "../providers/byok/registry.ts";
import { readBackboardConfig } from "./backboardConfig.ts";
import { type BackboardEnv, resolveApiUrl } from "./env.ts";

Expand All @@ -31,6 +26,7 @@ export interface AuthState {
backboard: BackboardEnv | null;
/** Saved provider keys that are currently toggled on. */
providerKeys: ResolvedProviderKey[];
providerRegistry: ProviderRegistry;
}

export interface ResolveAuthOptions {
Expand All @@ -43,12 +39,19 @@ export function resolveAuth(options: ResolveAuthOptions = {}): AuthState {
const apiKey = isUsableApiKey(envApiKey) ? envApiKey : fileConfig.apiKey;
const apiUrl = resolveApiUrl(fileConfig.apiUrl);

const providerRegistry = new ProviderRegistry(fileConfig.providers ?? []);
const savedKeys = readProviderKeys(options.homeDir);
const providerKeys = providerRegistry.adapters.flatMap((adapter) => {
const key = providerRegistry.credentialFor(
adapter.id,
savedKeys[adapter.id],
);
return key === null ? [] : [{ provider: adapter.id, key }];
});
return {
backboard: apiKey ? { apiKey, apiUrl } : null,
// Env vars are deliberately not consulted: a provider key becomes usable
// only by being added through the BYOK flow or `/keys`, so what the CLI
// bills to is always something the user chose explicitly.
providerKeys: enabledProviderKeys(readProviderKeys(options.homeDir)),
providerKeys,
providerRegistry,
};
}

Expand All @@ -59,7 +62,7 @@ export function hasAnyCredentials(auth: AuthState): boolean {
/** Builds the provider -> key lookup `ByokClient` and `ClientRouter` use. */
export function providerKeyResolver(
auth: AuthState,
): (provider: ByokProviderId) => string | null {
): (provider: string) => string | null {
const byProvider = new Map(
auth.providerKeys.map((entry) => [entry.provider, entry.key]),
);
Expand Down
56 changes: 55 additions & 1 deletion src/config/backboardConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
parseMemoryProfile,
type ThinkingLevel,
} from "./defaults.ts";
import { parseCustomProviders } from "./providers.ts";

export type { BackboardConfigFile } from "./BackboardConfigTypes.ts";

Expand Down Expand Up @@ -50,6 +51,7 @@ export function readBackboardConfig(
memoryProfile: readMemoryProfileConfig(config),
notify: typeof config.notify === "boolean" ? config.notify : undefined,
verbose: typeof config.verbose === "boolean" ? config.verbose : undefined,
providers: parseCustomProviders(config.providers),

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.

Entries that fail parsing are dropped here, and every writer spreads this result back to disk. A hand-edited provider with a typo gets deleted on the next unrelated save (/model, logout, etc.) with no warning.

expert: readExpertConfig(config),
};
} catch (err) {
Expand Down Expand Up @@ -194,17 +196,56 @@ export async function saveBackboardConfig(
): Promise<string> {
const file = backboardConfigPath(homeDir);
const dir = path.dirname(file);
const opaqueProviders = readOpaqueProviderEntries(file);
const output =
opaqueProviders.length === 0
? config
: {
...config,
providers: [...(config.providers ?? []), ...opaqueProviders],
};

await mkdir(dir, { recursive: true, mode: 0o700 });
await chmod(dir, 0o700).catch(() => undefined);
await writeFile(file, `${JSON.stringify(config, null, 2)}\n`, {
await writeFile(file, `${JSON.stringify(output, null, 2)}\n`, {
mode: 0o600,
});
await chmod(file, 0o600).catch(() => undefined);

return file;
}

function readOpaqueProviderEntries(file: string): JsonValue[] {
let parsed: JsonValue;
try {
parsed = JSON.parse(readFileSync(file, "utf8")) as JsonValue;
} catch (err) {
if ((err as { code?: string }).code === "ENOENT") return [];
throw new Error(
`Failed to preserve providers in ${file}: ${errorMessage(err)}`,
);
}
if (
typeof parsed !== "object" ||
parsed === null ||
Array.isArray(parsed) ||
!Array.isArray(parsed.providers)
) {
return [];
}
const opaque: JsonValue[] = [];
const seen = new Set<string>();
for (const entry of parsed.providers) {
const provider = parseCustomProviders([entry])?.[0];
if (!provider || seen.has(provider.id)) {
opaque.push(entry);
continue;
}
seen.add(provider.id);
}
return opaque;
}

export async function deleteBackboardConfig(
homeDir = os.homedir(),
): Promise<{ path: string; removed: boolean }> {
Expand All @@ -219,3 +260,16 @@ export async function deleteBackboardConfig(
throw new Error(`Failed to delete ${file}: ${errorMessage(err)}`);
}
}

/** Removes only the Backboard credential while preserving local preferences/providers. */
export async function clearBackboardCredential(
homeDir = os.homedir(),
): Promise<{ path: string; removed: boolean }> {
const existing = readBackboardConfig(homeDir);
if (!existing.apiKey) {
return { path: backboardConfigPath(homeDir), removed: false };
}
const { apiKey: _removed, ...rest } = existing;
await saveBackboardConfig(rest, homeDir);
return { path: backboardConfigPath(homeDir), removed: true };
}
Loading