feat(auth): add Anthropic OAuth support and deferred credential loading

- Read Claude Code's OAuth token from ~/.claude/.credentials.json as
  a fallback source for auth_mode: oauth (with expiry checking)
- Fix OAuth callback server to bind to localhost (not 127.0.0.1) and
  use JSON content type for token exchange
- Null out apiKey when authToken is set to prevent SDK from falling
  back to ANTHROPIC_API_KEY env var (routes to wrong billing)
- Add DeferredErrorClient so daemon starts even when credentials are
  missing, surfacing the error on first chat() call instead of crash
- Prompt to complete OAuth flow immediately when setting auth_mode to
  oauth with no token stored

Note: Anthropic currently rejects OAuth for API access (Feb 2026
policy change), but the plumbing is in place for if/when re-enabled.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
William Valentin
2026-02-27 13:03:01 -08:00
parent 487e5c2930
commit 49a5a44c8a
4 changed files with 97 additions and 14 deletions
+33 -6
View File
@@ -78,6 +78,33 @@ function resolveZaiCredential(cfg: ModelConfig): string {
return raw.startsWith('Bearer ') ? raw.slice('Bearer '.length) : raw;
}
/**
* A ModelClient that defers a credential error to the first chat() call.
* Used so the daemon can start even when credentials are not yet configured.
*/
class DeferredErrorClient implements ModelClient {
constructor(private readonly error: Error) {}
chat(): Promise<never> {
return Promise.reject(this.error);
}
}
/**
* Like createClientFromConfig but never throws at construction time.
* If credentials are missing, returns a DeferredErrorClient that will
* surface the error on the first chat() call.
*/
export function createClientFromConfigOrDeferred(cfg: ModelConfig): ModelClient {
try {
return createClientFromConfig(cfg);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
logger.warn(`Deferred credential error for provider "${cfg.provider}": ${err.message}`);
return new DeferredErrorClient(err);
}
}
/**
* Create a ModelClient from a provider config entry.
* Dispatches on the `provider` field so all tiers and fallback entries
@@ -391,11 +418,11 @@ export function createAutoFallbackClient(tierConfig: { provider: string; model:
export function createModelRouter(config: Config): ModelRouter {
const models = config.models;
const defaultClient = createClientFromConfig(models.default);
const defaultClient = createClientFromConfigOrDeferred(models.default);
const fastClient = models.fast ? createClientFromConfig(models.fast) : undefined;
const complexClient = models.complex ? createClientFromConfig(models.complex) : undefined;
const localClient = models.local ? createClientFromConfig(models.local) : undefined;
const fastClient = models.fast ? createClientFromConfigOrDeferred(models.fast) : undefined;
const complexClient = models.complex ? createClientFromConfigOrDeferred(models.complex) : undefined;
const localClient = models.local ? createClientFromConfigOrDeferred(models.local) : undefined;
// Build fallback chain — each entry references a tier name or 'local'
const fallbackChain: ModelClient[] = [];
@@ -411,7 +438,7 @@ export function createModelRouter(config: Config): ModelRouter {
fallbackChain.push(complexClient);
} else if (models.local_providers?.[providerName]) {
// Named provider from local_providers map
fallbackChain.push(createClientFromConfig(models.local_providers[providerName]));
fallbackChain.push(createClientFromConfigOrDeferred(models.local_providers[providerName]));
} else {
logger.warn(`Fallback chain entry "${providerName}" not found — skipping`);
}
@@ -448,7 +475,7 @@ export function createModelRouter(config: Config): ModelRouter {
// User-configured inline fallback
if (cfg.fallback) {
fallbackList.push(createClientFromConfig(cfg.fallback));
fallbackList.push(createClientFromConfigOrDeferred(cfg.fallback));
}
if (fallbackList.length > 0) {