Skip to content
Merged
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
205 changes: 176 additions & 29 deletions .opencode/lib/ai-research.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
* - Domain shorthand auto-resolves to relevant agents via subagent-index.toon
* - Extracts AI-CONTEXT-START/END sections to minimise tokens
* - Rate-limited to 10 calls per session
* - Calls Anthropic API directly (no CLI overhead)
* - Auth: reads OAuth token from ~/.local/share/opencode/auth.json (primary),
* falls back to ANTHROPIC_API_KEY env var. OAuth uses the anthropic-beta
* header (oauth-2025-04-20) for direct API access.
*/

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -40,7 +42,7 @@
const AGENTS_BASE = `${process.env.HOME || "~"}/.aidevops/agents`

const MODEL_MAP: Record<string, string> = {
haiku: "claude-3-5-haiku-20241022",
haiku: "claude-3-haiku-20240307",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, verify the file exists and read the MODEL_MAP section
if [ -f ".opencode/lib/ai-research.ts" ]; then
  echo "=== File found. Reading MODEL_MAP section (lines 40-55) ==="
  head -n 55 ".opencode/lib/ai-research.ts" | tail -n 20
else
  echo "File not found at .opencode/lib/ai-research.ts"
  find . -name "ai-research.ts" -type f 2>/dev/null
fi

Repository: marcusquinn/aidevops

Length of output: 740


🏁 Script executed:

#!/bin/bash
# Search for any comments or context around the model choice
rg -A 5 -B 5 "claude-3.*haiku" .opencode/lib/ai-research.ts

Repository: marcusquinn/aidevops

Length of output: 398


🏁 Script executed:

#!/bin/bash
# Check git history for this specific line if .git exists
if [ -d ".git" ]; then
  echo "=== Git blame for the haiku line ==="
  git blame -L 40,50 ".opencode/lib/ai-research.ts" 2>/dev/null || echo "Git blame not available"
  echo ""
  echo "=== Recent commits to this file ==="
  git log --oneline -n 10 -- ".opencode/lib/ai-research.ts" 2>/dev/null || echo "Git log not available"
else
  echo "No .git directory found"
fi

Repository: marcusquinn/aidevops

Length of output: 1139


🏁 Script executed:

#!/bin/bash
# Search for any comments mentioning OAuth, model selection, or versioning decisions
rg -i "oauth|model.*version|3\.5|3\.0|haiku.*reason|claude.*downgrade|claude.*downgrade" ".opencode/lib/ai-research.ts" -A 2 -B 2

Repository: marcusquinn/aidevops

Length of output: 2673


🏁 Script executed:

#!/bin/bash
# Check the entire MODEL_MAP to understand the pattern
rg -A 20 "MODEL_MAP\s*=" ".opencode/lib/ai-research.ts"

Repository: marcusquinn/aidevops

Length of output: 46


Clarify rationale for haiku model downgrade — version pattern inconsistency

The haiku model is pinned to Claude 3.0 (claude-3-haiku-20240307), while sonnet and opus use the latest versions (4-20250514). If this downgrade is required for OAuth/beta compatibility, document it. Otherwise, Claude 3.5 Haiku offers better quality at the same tier. This version inconsistency should be intentional and explained.

🤖 Prompt for AI Agents
In @.opencode/lib/ai-research.ts at line 45, The haiku model entry (haiku:
"claude-3-haiku-20240307") is pinned to an older Claude 3.0 version while other
models (sonnet, opus) use 4-20250514; either update the haiku value to the
intended current model (e.g., claude-3.5-haiku or the matching 4-2025..
identifier) or add a clarifying comment/docstring explaining why the downgrade
is intentional for OAuth/beta compatibility. Locate the model mapping where the
haiku key is defined in ai-research.ts (the haiku property) and change the
string to the correct model ID or insert a one-line comment above the haiku
entry stating the compatibility reason and date/version, so the inconsistency is
explicit.

sonnet: "claude-sonnet-4-20250514",
opus: "claude-opus-4-20250514",
}
Expand Down Expand Up @@ -281,46 +283,166 @@
}

// ---------------------------------------------------------------------------
// Anthropic API call
// OAuth token management
// ---------------------------------------------------------------------------

function getApiKey(): string {
// Check environment variable first
const key = process.env.ANTHROPIC_API_KEY
if (key) return key
const AUTH_FILE = `${process.env.HOME || "~"}/.local/share/opencode/auth.json`
const OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To improve maintainability and avoid magic strings, it's a good practice to define all API endpoint URLs as constants. You can define the OAuth token URL and the Anthropic API URL here, and then use these constants in refreshOAuthToken and callAnthropic.

const OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
const OAUTH_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
const ANTHROPIC_API_URL = "https://api.anthropic.com/v1/messages"


throw new Error(
"ANTHROPIC_API_KEY not found. Set it via: aidevops secret set ANTHROPIC_API_KEY"
interface OAuthAuth {
type: "oauth"
refresh: string
access: string
expires: number
}

interface ApiAuth {
type: "api"
key: string
}

type AuthEntry = OAuthAuth | ApiAuth

/**
* Read the Anthropic auth entry from OpenCode's auth.json.
* Returns null if the file doesn't exist or has no anthropic entry.
*/
async function readAuthFile(): Promise<AuthEntry | null> {
try {
const file = Bun.file(AUTH_FILE)
if (!(await file.exists())) return null
const data = await file.json()
return data.anthropic || null
} catch {
return null
}
}

/**
* Refresh an expired OAuth access token using the refresh token.
* Updates auth.json with the new tokens.
*/
async function refreshOAuthToken(auth: OAuthAuth): Promise<string> {
const response = await fetch(
"https://console.anthropic.com/v1/oauth/token",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "refresh_token",
refresh_token: auth.refresh,
client_id: OAUTH_CLIENT_ID,
}),
}
)

if (!response.ok) {
throw new Error(`OAuth token refresh failed (${response.status})`)
}

const json = (await response.json()) as {
access_token: string
refresh_token: string
expires_in: number
}

// Update auth.json with new tokens
try {
const file = Bun.file(AUTH_FILE)
const data = await file.json()
data.anthropic = {
type: "oauth",
refresh: json.refresh_token,
access: json.access_token,
expires: Date.now() + json.expires_in * 1000,
}
await Bun.write(AUTH_FILE, JSON.stringify(data, null, 2))
} catch {
// Non-fatal: token still works for this request even if we can't persist
}
Comment on lines +360 to +362

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This empty catch block silently ignores errors when persisting the refreshed OAuth token. While this is non-fatal for the current request, it can lead to repeated token refreshes on every subsequent execution if there's a persistent file write issue (e.g., incorrect permissions). This is inefficient and could lead to rate-limiting. Consider logging a warning to inform the user about the persistence failure.

  } catch (e) {
    // Non-fatal: token still works for this request even if we can't persist
    console.warn(`[ai-research] Failed to persist refreshed OAuth token: ${e instanceof Error ? e.message : String(e)}`)
  }


return json.access_token
}
Comment on lines +325 to 365
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Token refresh lacks a timeout and response validation.

Two defensive gaps in refreshOAuthToken:

  1. No fetch timeout — if console.anthropic.com is unresponsive, this hangs indefinitely, blocking the caller.
  2. No validation of the response body — if the response lacks access_token or refresh_token, the function returns undefined as the access token and persists broken state to auth.json.
🛡️ Suggested hardening
  const response = await fetch(
    "https://console.anthropic.com/v1/oauth/token",
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        grant_type: "refresh_token",
        refresh_token: auth.refresh,
        client_id: OAUTH_CLIENT_ID,
      }),
+     signal: AbortSignal.timeout(15_000),
    }
  )

  if (!response.ok) {
    throw new Error(`OAuth token refresh failed (${response.status})`)
  }

  const json = (await response.json()) as {
    access_token: string
    refresh_token: string
    expires_in: number
  }

+ if (!json.access_token || !json.refresh_token) {
+   throw new Error("OAuth token refresh returned incomplete response")
+ }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function refreshOAuthToken(auth: OAuthAuth): Promise<string> {
const response = await fetch(
"https://console.anthropic.com/v1/oauth/token",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "refresh_token",
refresh_token: auth.refresh,
client_id: OAUTH_CLIENT_ID,
}),
}
)
if (!response.ok) {
throw new Error(`OAuth token refresh failed (${response.status})`)
}
const json = (await response.json()) as {
access_token: string
refresh_token: string
expires_in: number
}
// Update auth.json with new tokens
try {
const file = Bun.file(AUTH_FILE)
const data = await file.json()
data.anthropic = {
type: "oauth",
refresh: json.refresh_token,
access: json.access_token,
expires: Date.now() + json.expires_in * 1000,
}
await Bun.write(AUTH_FILE, JSON.stringify(data, null, 2))
} catch {
// Non-fatal: token still works for this request even if we can't persist
}
return json.access_token
}
async function refreshOAuthToken(auth: OAuthAuth): Promise<string> {
const response = await fetch(
"https://console.anthropic.com/v1/oauth/token",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "refresh_token",
refresh_token: auth.refresh,
client_id: OAUTH_CLIENT_ID,
}),
signal: AbortSignal.timeout(15_000),
}
)
if (!response.ok) {
throw new Error(`OAuth token refresh failed (${response.status})`)
}
const json = (await response.json()) as {
access_token: string
refresh_token: string
expires_in: number
}
if (!json.access_token || !json.refresh_token) {
throw new Error("OAuth token refresh returned incomplete response")
}
// Update auth.json with new tokens
try {
const file = Bun.file(AUTH_FILE)
const data = await file.json()
data.anthropic = {
type: "oauth",
refresh: json.refresh_token,
access: json.access_token,
expires: Date.now() + json.expires_in * 1000,
}
await Bun.write(AUTH_FILE, JSON.stringify(data, null, 2))
} catch {
// Non-fatal: token still works for this request even if we can't persist
}
return json.access_token
}
🤖 Prompt for AI Agents
In @.opencode/lib/ai-research.ts around lines 325 - 365, The refreshOAuthToken
function currently can hang and may persist invalid tokens; add a fetch timeout
(use AbortController with a reasonable timeout) when calling
fetch("https://console.anthropic.com/v1/oauth/token") and abort the request on
timeout, and after parsing the JSON validate that json.access_token and
json.refresh_token (and expires_in) are present and of the expected types; if
validation fails, throw a clear Error (do not return undefined) and avoid
writing to AUTH_FILE; only update Bun.file(AUTH_FILE) / Bun.write(AUTH_FILE,
...) when validated fields exist, and keep the existing try/catch around the
write so write failures remain non-fatal but log or include the underlying error
in the thrown or logged message where appropriate (referencing
refreshOAuthToken, AUTH_FILE, OAUTH_CLIENT_ID, Bun.file, Bun.write).


export async function research(req: ResearchRequest): Promise<ResearchResult> {
checkRateLimit()
// ---------------------------------------------------------------------------
// Auth resolution
// ---------------------------------------------------------------------------

const modelTier = req.model || "haiku"
const modelId = MODEL_MAP[modelTier]
if (!modelId) {
throw new Error(
`Unknown model tier: ${modelTier}. Use: haiku, sonnet, or opus`
)
interface ResolvedAuth {
method: "oauth" | "api-key"
token: string
}

/**
* Resolve authentication. Priority:
* 1. OAuth from auth.json (primary — no API key needed)
* 2. ANTHROPIC_API_KEY env var (fallback)
*/
async function resolveAuth(): Promise<ResolvedAuth> {
// Try OAuth from auth.json first
const auth = await readAuthFile()

if (auth?.type === "oauth") {
let accessToken = auth.access
if (!accessToken || auth.expires < Date.now()) {
accessToken = await refreshOAuthToken(auth)
}
return { method: "oauth", token: accessToken }
}
Comment on lines +385 to 391
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add an expiry buffer to avoid using near-expired tokens.

Line 387 checks auth.expires < Date.now() with zero margin. If the token expires in a few seconds, the subsequent API call will fail mid-flight. Standard practice is to add a buffer (e.g., 60 seconds) to proactively refresh before expiry.

🛡️ Suggested fix
+ const EXPIRY_BUFFER_MS = 60_000 // refresh 60s before expiry
+
  if (auth?.type === "oauth") {
    let accessToken = auth.access
-   if (!accessToken || auth.expires < Date.now()) {
+   if (!accessToken || auth.expires < Date.now() + EXPIRY_BUFFER_MS) {
      accessToken = await refreshOAuthToken(auth)
    }
    return { method: "oauth", token: accessToken }
  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (auth?.type === "oauth") {
let accessToken = auth.access
if (!accessToken || auth.expires < Date.now()) {
accessToken = await refreshOAuthToken(auth)
}
return { method: "oauth", token: accessToken }
}
const EXPIRY_BUFFER_MS = 60_000 // refresh 60s before expiry
if (auth?.type === "oauth") {
let accessToken = auth.access
if (!accessToken || auth.expires < Date.now() + EXPIRY_BUFFER_MS) {
accessToken = await refreshOAuthToken(auth)
}
return { method: "oauth", token: accessToken }
}
🤖 Prompt for AI Agents
In @.opencode/lib/ai-research.ts around lines 385 - 391, The token expiry check
in the oauth branch (the block that returns { method: "oauth", token:
accessToken }) compares auth.expires to Date.now() with no margin; change the
condition to proactively refresh when the token is near expiry by using a buffer
(e.g., 60_000 ms). Specifically, in the code that reads auth?.type === "oauth",
update the expiry check from auth.expires < Date.now() to something like
auth.expires < Date.now() + EXPIRY_BUFFER (or auth.expires - EXPIRY_BUFFER <
Date.now()) and define EXPIRY_BUFFER = 60_000 (or similar) in scope so
refreshOAuthToken(auth) is called before the token is about to expire.


const maxTokens = req.max_tokens || 500
const systemPrompt = await buildSystemPrompt(
req.agents,
req.domain,
req.files
// auth.json has an API key entry
if (auth?.type === "api" && (auth as ApiAuth).key) {
return { method: "api-key", token: (auth as ApiAuth).key }
}

// Fall back to env var
const envKey = process.env.ANTHROPIC_API_KEY
if (envKey) {
return { method: "api-key", token: envKey }
}

throw new Error(
"No Anthropic auth found. Either:\n" +
" 1. Run `opencode auth` to set up OAuth (recommended)\n" +
" 2. Set ANTHROPIC_API_KEY environment variable"
)
}

const apiKey = getApiKey()
// ---------------------------------------------------------------------------
// Anthropic API call
// ---------------------------------------------------------------------------

const response = await fetch("https://api.anthropic.com/v1/messages", {
/**
* Call the Anthropic Messages API with resolved auth.
* OAuth uses Bearer token + anthropic-beta header.
* API key uses x-api-key header.
*/
async function callAnthropic(
req: ResearchRequest,
auth: ResolvedAuth,
modelId: string,
systemPrompt: string
): Promise<ResearchResult> {
const maxTokens = req.max_tokens || 500

const headers: Record<string, string> = {
"Content-Type": "application/json",
"anthropic-version": "2023-06-01",
}

let url = "https://api.anthropic.com/v1/messages"

if (auth.method === "oauth") {
headers["authorization"] = `Bearer ${auth.token}`

Check warning on line 436 in .opencode/lib/ai-research.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

.opencode/lib/ai-research.ts#L436

JSON stringify does not produce a stable key ordering, and should not be relied on for producing object keys.
headers["anthropic-beta"] = "oauth-2025-04-20"
url += "?beta=true"
} else {
headers["x-api-key"] = auth.token

Check warning on line 440 in .opencode/lib/ai-research.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

.opencode/lib/ai-research.ts#L440

JSON stringify does not produce a stable key ordering, and should not be relied on for producing object keys.
}

const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
},
headers,
body: JSON.stringify({
model: modelId,
max_tokens: maxTokens,
Expand Down Expand Up @@ -352,3 +474,28 @@
calls_remaining: getCallsRemaining(),
}
}

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

export async function research(req: ResearchRequest): Promise<ResearchResult> {
checkRateLimit()

const modelTier = req.model || "haiku"
const modelId = MODEL_MAP[modelTier]
if (!modelId) {
throw new Error(
`Unknown model tier: ${modelTier}. Use: haiku, sonnet, or opus`
)
}

const systemPrompt = await buildSystemPrompt(
req.agents,
req.domain,
req.files
)

const auth = await resolveAuth()
return callAnthropic(req, auth, modelId, systemPrompt)
}
Loading