Add credential usage field: chat, agent, or any
Some checks failed
CI / lint-and-test (push) Successful in 40s
Deploy Production / deploy (push) Successful in 1m23s
CI / build (push) Has been cancelled

Credentials now have a usage field ("any", "chat", "agent") so the
same provider can have separate credentials for different use cases.
The Claude OAuth token is tagged "agent" (for Claude Code CLI), while
API keys can be tagged "chat" (for streaming/model listing).

- Add usage column to harness_credentials table
- Add getCredentialsForUsage() that prefers exact match, falls back to "any"
- Chat route and model-providers use chat credentials
- Agent env builder uses agent credentials
- Credentials API accepts usage field on create/update
This commit is contained in:
Julia McGhee
2026-03-22 12:44:02 +00:00
parent 37fc127a21
commit f338da0da3
10 changed files with 861 additions and 7 deletions

View File

@@ -1,6 +1,6 @@
import { NextRequest } from "next/server";
import { z } from "zod";
import { getRawCredentialsByProvider, Provider } from "@/lib/credentials";
import { getCredentialsForUsage, Provider } from "@/lib/credentials";
import { executeChatToolCall, chatToolsAsAnthropic, chatToolsAsOpenAI } from "@/lib/chat-tools";
import {
streamAnthropic,
@@ -78,7 +78,7 @@ export async function POST(request: NextRequest) {
const { model, provider, projectId, conversationId } = body;
// Look up credentials
const creds = await getRawCredentialsByProvider(provider as Provider);
const creds = await getCredentialsForUsage(provider as Provider, "chat");
if (creds.length === 0) {
return Response.json(
{ error: `No credentials configured for provider: ${provider}` },

View File

@@ -42,6 +42,7 @@ export async function POST(request: NextRequest) {
label: body.label,
token: body.token,
baseUrl: body.baseUrl,
usage: body.usage || "any",
};
const saved = await upsertCredential(cred);

View File

@@ -1,5 +1,5 @@
import { AgentConfig, AGENT_RUNTIMES } from "./agents";
import { getRawCredentialsByProvider, Provider } from "./credentials";
import { getCredentialsForUsage, getRawCredentialsByProvider, Provider } from "./credentials";
const PROVIDER_ENV_VARS: Record<string, string> = {
anthropic: "ANTHROPIC_API_KEY",
@@ -28,7 +28,7 @@ export async function buildAgentEnv(
for (const provider of providersToInject) {
const envVar = PROVIDER_ENV_VARS[provider];
if (!envVar) continue;
const creds = await getRawCredentialsByProvider(provider as Provider);
const creds = await getCredentialsForUsage(provider as Provider, "agent");
if (creds.length > 0) {
env[envVar] = creds[0].token;
}

View File

@@ -60,7 +60,7 @@ async function loadCredentialsFromEnv() {
if (!token) continue;
const existing = await getRawCredentialsByProvider(provider);
if (existing.length > 0) continue;
await upsertCredential({ id: `env-${provider}`, provider, label, token });
await upsertCredential({ id: `env-${provider}`, provider, label, token, usage: "any" });
}
// Always sync Gitea baseUrl from GITEA_URL env var
@@ -90,6 +90,7 @@ async function loadClaudeCredentials() {
provider: "anthropic",
label: `Claude ${raw.claudeAiOauth.subscriptionType || "API"} (mounted)`,
token: raw.claudeAiOauth.accessToken,
usage: "agent",
});
}
} catch {
@@ -127,6 +128,7 @@ async function loadOpenCodeCredentials() {
provider,
label: `${key} (mounted)`,
token,
usage: "any",
});
}
}

View File

@@ -11,12 +11,15 @@ export const GIT_PROVIDERS: Provider[] = ["github", "gitlab", "gitea"];
export const AI_PROVIDERS: Provider[] = ["anthropic", "openai", "openrouter", "google", "opencode-zen", "opencode-go"];
export const INFRA_PROVIDERS: Provider[] = ["postgres-mcp"];
export type CredentialUsage = "any" | "chat" | "agent";
export interface Credential {
id: string;
provider: Provider;
label: string;
token: string;
baseUrl?: string;
usage: CredentialUsage;
}
function maskToken(token: string): string {
@@ -31,6 +34,7 @@ function rowToCredential(row: typeof credentialsTable.$inferSelect): Credential
label: row.label,
token: row.token,
baseUrl: row.baseUrl ?? undefined,
usage: (row.usage as CredentialUsage) || "any",
};
}
@@ -62,6 +66,15 @@ export async function getRawCredentialsByProvider(provider: Provider): Promise<C
return rows.map(rowToCredential);
}
/** Get credentials for a specific use case. Returns matching usage first, then "any" as fallback. */
export async function getCredentialsForUsage(provider: Provider, usage: "chat" | "agent"): Promise<Credential[]> {
const all = await getRawCredentialsByProvider(provider);
// Prefer exact match, fall back to "any"
const exact = all.filter((c) => c.usage === usage);
if (exact.length > 0) return exact;
return all.filter((c) => c.usage === "any");
}
export async function upsertCredential(cred: Credential): Promise<Credential> {
await db.insert(credentialsTable).values({
id: cred.id,
@@ -69,6 +82,7 @@ export async function upsertCredential(cred: Credential): Promise<Credential> {
label: cred.label,
token: cred.token,
baseUrl: cred.baseUrl,
usage: cred.usage || "any",
}).onConflictDoUpdate({
target: credentialsTable.id,
set: {
@@ -76,6 +90,7 @@ export async function upsertCredential(cred: Credential): Promise<Credential> {
label: cred.label,
token: cred.token,
baseUrl: cred.baseUrl,
usage: cred.usage || "any",
updatedAt: new Date(),
},
});

View File

@@ -1,4 +1,4 @@
import { getRawCredentialsByProvider } from "./credentials";
import { getRawCredentialsByProvider, getCredentialsForUsage } from "./credentials";
export interface ModelInfo {
id: string;
@@ -94,7 +94,10 @@ export async function fetchAllModels(): Promise<ModelInfo[]> {
}
async function fetchAnthropicModels(): Promise<ModelInfo[]> {
const creds = await getRawCredentialsByProvider("anthropic");
// Prefer chat credentials (API keys) over agent credentials (OAuth tokens)
const chatCreds = await getCredentialsForUsage("anthropic", "chat");
const allCreds = await getRawCredentialsByProvider("anthropic");
const creds = chatCreds.length > 0 ? chatCreds : allCreds;
if (creds.length === 0) return [];
for (const cred of creds) {

View File

@@ -0,0 +1 @@
ALTER TABLE "harness_credentials" ADD COLUMN "usage" text DEFAULT 'any' NOT NULL;

View File

@@ -0,0 +1,824 @@
{
"id": "2ecc8bb5-604b-4c54-89a6-d9e0ff7e88a6",
"prevId": "c8c9e029-bf55-4d40-9bd1-f666aee2e08b",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.harness_agent_configs": {
"name": "harness_agent_configs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"runtime": {
"name": "runtime",
"type": "text",
"primaryKey": false,
"notNull": true
},
"model_id": {
"name": "model_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"max_tokens": {
"name": "max_tokens",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"env": {
"name": "env",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.harness_chat_conversations": {
"name": "harness_chat_conversations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"workspace": {
"name": "workspace",
"type": "text",
"primaryKey": false,
"notNull": false
},
"messages": {
"name": "messages",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'[]'::jsonb"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.harness_credentials": {
"name": "harness_credentials",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"base_url": {
"name": "base_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"usage": {
"name": "usage",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'any'"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.harness_event_log": {
"name": "harness_event_log",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"trigger_id": {
"name": "trigger_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"delivery_id": {
"name": "delivery_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"event_type": {
"name": "event_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"repo": {
"name": "repo",
"type": "text",
"primaryKey": false,
"notNull": true
},
"commit_sha": {
"name": "commit_sha",
"type": "text",
"primaryKey": false,
"notNull": false
},
"branch": {
"name": "branch",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'received'"
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"skip_reason": {
"name": "skip_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"payload": {
"name": "payload",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"harness_event_log_delivery_id_unique": {
"name": "harness_event_log_delivery_id_unique",
"nullsNotDistinct": false,
"columns": [
"delivery_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.harness_event_triggers": {
"name": "harness_event_triggers",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"event_type": {
"name": "event_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"repo_filter": {
"name": "repo_filter",
"type": "text",
"primaryKey": false,
"notNull": false
},
"state_filter": {
"name": "state_filter",
"type": "text",
"primaryKey": false,
"notNull": false
},
"context_filter": {
"name": "context_filter",
"type": "text",
"primaryKey": false,
"notNull": false
},
"task_template": {
"name": "task_template",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"consecutive_failures": {
"name": "consecutive_failures",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"max_consecutive_failures": {
"name": "max_consecutive_failures",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 3
},
"disabled_reason": {
"name": "disabled_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"webhook_secret": {
"name": "webhook_secret",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.harness_iterations": {
"name": "harness_iterations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"n": {
"name": "n",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"diagnosis": {
"name": "diagnosis",
"type": "text",
"primaryKey": false,
"notNull": false
},
"agent_output": {
"name": "agent_output",
"type": "text",
"primaryKey": false,
"notNull": false
},
"evals": {
"name": "evals",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"diff_stats": {
"name": "diff_stats",
"type": "text",
"primaryKey": false,
"notNull": false
},
"started_at": {
"name": "started_at",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"completed_at": {
"name": "completed_at",
"type": "bigint",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.harness_model_usage": {
"name": "harness_model_usage",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"model_id": {
"name": "model_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"task_id": {
"name": "task_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"task_slug": {
"name": "task_slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"iteration": {
"name": "iteration",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"input_tokens": {
"name": "input_tokens",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"output_tokens": {
"name": "output_tokens",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"duration_ms": {
"name": "duration_ms",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"timestamp": {
"name": "timestamp",
"type": "bigint",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.harness_orchestrator": {
"name": "harness_orchestrator",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"default": "'singleton'"
},
"running": {
"name": "running",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"current_task_id": {
"name": "current_task_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"heartbeat": {
"name": "heartbeat",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.harness_projects": {
"name": "harness_projects",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"workspaces": {
"name": "workspaces",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'[]'::jsonb"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.harness_tasks": {
"name": "harness_tasks",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"goal": {
"name": "goal",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"iteration": {
"name": "iteration",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"max_iterations": {
"name": "max_iterations",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 6
},
"started_at": {
"name": "started_at",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"completed_at": {
"name": "completed_at",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"project": {
"name": "project",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'—'"
},
"evals": {
"name": "evals",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"pr": {
"name": "pr",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"cancel_requested": {
"name": "cancel_requested",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"spec": {
"name": "spec",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -36,6 +36,13 @@
"when": 1774180634616,
"tag": "0004_complete_jack_murdock",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1774183202832,
"tag": "0005_outgoing_lake",
"breakpoints": true
}
]
}

View File

@@ -28,6 +28,7 @@ export const credentials = pgTable("harness_credentials", {
label: text("label").notNull(),
token: text("token").notNull(),
baseUrl: text("base_url"),
usage: text("usage").notNull().default("any"), // "any" | "chat" | "agent"
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});