Skip to content
Merged
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
4 changes: 2 additions & 2 deletions CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ authors:
- family-names: 'Leymet'
given-names: 'Arnaud'
title: 'DEVS'
version: 0.8.0
date-released: 2026-05-01
version: 0.8.1
date-released: 2026-08-01
url: 'https://github.com/codename-co/devs'
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "devs",
"private": true,
"version": "0.8.0",
"version": "0.8.1",
"license": "MIT",
"type": "module",
"sideEffects": false,
Expand Down
100 changes: 83 additions & 17 deletions src/lib/llm/ai-sdk/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
ToolDefinition,
ToolChoice,
FinishReason,
GroundingMetadata,
} from '../types'
import { stripModelPrefix } from '../types'

Expand Down Expand Up @@ -58,6 +59,14 @@ export interface AiSdkBinding {
providerOptions?(
config: FullConfig,
): Record<string, Record<string, unknown>> | undefined
/**
* Provider-executed (hosted) tools to merge into the request's tool set —
* e.g. Anthropic's native `web_search` tool when `enableWebSearch` is set.
* These run server-side on the provider; DEVS never executes them itself.
*/
providerTools?(
config: FullConfig,
): Promise<ToolSet | undefined> | ToolSet | undefined
/** Validate an API key / endpoint (thin GET). Omitted ⇒ always valid. */
validateApiKey?(apiKey: string, baseUrl?: string): Promise<boolean>
/** List models live from the provider (thin GET). Omitted ⇒ `[]`. */
Expand Down Expand Up @@ -108,21 +117,47 @@ function toAiToolChoice(
return undefined
}

/** Map an AI SDK tool-call → the canonical OpenAI-style ToolCall. */
function toCanonicalToolCall(tc: {
toolCallId: string
toolName: string
input: unknown
}): ToolCall {
return {
/** Map an AI SDK tool-call → the canonical OpenAI-style ToolCall. Filters
* out provider-executed calls (e.g. Anthropic's native web_search): those
* run server-side and must not be surfaced to DEVS' own tool-execution
* loop. */
function toCanonicalToolCalls(
calls:
| Array<{
toolCallId: string
toolName: string
input: unknown
providerExecuted?: boolean
}>
| undefined,
): ToolCall[] | undefined {
const clientCalls = calls?.filter((tc) => !tc.providerExecuted)
if (!clientCalls || clientCalls.length === 0) return undefined
return clientCalls.map((tc) => ({
id: tc.toolCallId,
type: 'function',
function: {
name: tc.toolName,
arguments:
typeof tc.input === 'string' ? tc.input : JSON.stringify(tc.input ?? {}),
typeof tc.input === 'string'
? tc.input
: JSON.stringify(tc.input ?? {}),
},
}
}))
}

/** Map AI SDK `sources` (unified across providers' web search / grounding) →
* the canonical {@link GroundingMetadata}. */
function toGroundingMetadata(
sources:
| Array<{ sourceType: string; url?: string; title?: string }>
| undefined,
): GroundingMetadata | undefined {
const webResults = (sources ?? [])
.filter((s) => s.sourceType === 'url' && s.url)
.map((s) => ({ title: s.title || s.url!, url: s.url! }))
if (webResults.length === 0) return undefined
return { isGrounded: true, webResults }
}

/**
Expand Down Expand Up @@ -184,12 +219,25 @@ export class AiSdkProvider implements LLMProviderInterface {
return { ai, binding, model }
}

/** Merge DEVS' client tool definitions with the binding's provider-executed
* (hosted) tools, e.g. Anthropic's native `web_search`. */
private async tools(
ai: typeof import('ai'),
binding: AiSdkBinding,
config?: FullConfig,
): Promise<ToolSet | undefined> {
const definedTools = toAiTools(config?.tools, ai.tool, ai.jsonSchema)
const providerTools = await binding.providerTools?.(config ?? {})
if (!definedTools && !providerTools) return undefined
return { ...definedTools, ...providerTools }
}

async chat(
messages: LLMMessage[],
config?: FullConfig,
): Promise<LLMResponseWithTools> {
const { ai, binding, model } = await this.build(config)
const tools = toAiTools(config?.tools, ai.tool, ai.jsonSchema)
const tools = await this.tools(ai, binding, config)
const providerOptions = binding.providerOptions?.(config ?? {})

const result = await ai.generateText({
Expand All @@ -198,19 +246,27 @@ export class AiSdkProvider implements LLMProviderInterface {
allowSystemInMessages: true,
temperature: config?.temperature ?? 0.7,
...(config?.maxTokens ? { maxOutputTokens: config.maxTokens } : {}),
...(tools ? { tools, toolChoice: toAiToolChoice(config?.tool_choice) } : {}),
...(tools
? { tools, toolChoice: toAiToolChoice(config?.tool_choice) }
: {}),
...(providerOptions
? { providerOptions: providerOptions as ProviderOptions }
: {}),
abortSignal: config?.signal,
})

const toolCalls = result.toolCalls?.map(toCanonicalToolCall)
const toolCalls = toCanonicalToolCalls(result.toolCalls)
const groundingMetadata = toGroundingMetadata(result.sources)
// A provider-executed web search doesn't need a client round-trip: if
// every tool call was filtered out as provider-executed, the turn is done.
const finishReason = mapFinishReason(result.finishReason)
return {
content: result.text ?? '',
...(result.reasoningText ? { thinking: result.reasoningText } : {}),
tool_calls: toolCalls && toolCalls.length > 0 ? toolCalls : undefined,
finish_reason: mapFinishReason(result.finishReason),
tool_calls: toolCalls,
...(groundingMetadata ? { groundingMetadata } : {}),
finish_reason:
finishReason === 'tool_calls' && !toolCalls ? 'stop' : finishReason,
usage: result.usage
? {
promptTokens: result.usage.inputTokens ?? 0,
Expand All @@ -226,7 +282,7 @@ export class AiSdkProvider implements LLMProviderInterface {
config?: FullConfig,
): AsyncIterableIterator<string> {
const { ai, binding, model } = await this.build(config)
const tools = toAiTools(config?.tools, ai.tool, ai.jsonSchema)
const tools = await this.tools(ai, binding, config)
const providerOptions = binding.providerOptions?.(config ?? {})

const result = ai.streamText({
Expand All @@ -235,7 +291,9 @@ export class AiSdkProvider implements LLMProviderInterface {
allowSystemInMessages: true,
temperature: config?.temperature ?? 0.7,
...(config?.maxTokens ? { maxOutputTokens: config.maxTokens } : {}),
...(tools ? { tools, toolChoice: toAiToolChoice(config?.tool_choice) } : {}),
...(tools
? { tools, toolChoice: toAiToolChoice(config?.tool_choice) }
: {}),
...(providerOptions
? { providerOptions: providerOptions as ProviderOptions }
: {}),
Expand All @@ -246,9 +304,17 @@ export class AiSdkProvider implements LLMProviderInterface {
if (delta) yield delta
}

// Grounding metadata (web search sources) is flushed before tool calls —
// mirrors the legacy Google provider's stream ordering, which
// `parseToolCallsFromStream` relies on.
const groundingMetadata = toGroundingMetadata(await result.sources)
if (groundingMetadata) {
yield `\n__GROUNDING_METADATA__${JSON.stringify(groundingMetadata)}`
}

// Preserve the legacy streaming protocol: tool calls are flushed at the end
// as a `__TOOL_CALLS__`-prefixed JSON marker (parsed by the agent loop).
const toolCalls = (await result.toolCalls)?.map(toCanonicalToolCall)
const toolCalls = toCanonicalToolCalls(await result.toolCalls)
if (toolCalls && toolCalls.length > 0) {
yield `\n__TOOL_CALLS__${JSON.stringify(toolCalls)}`
}
Expand Down
29 changes: 24 additions & 5 deletions src/lib/llm/ai-sdk/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ function bearer(apiKey?: string): Record<string, string> {
}

/** Validate a key against an OpenAI-style `GET {base}/models`. */
async function openAiStyleValidate(base: string, apiKey?: string): Promise<boolean> {
async function openAiStyleValidate(
base: string,
apiKey?: string,
): Promise<boolean> {
try {
const res = await fetch(`${base}/models`, { headers: bearer(apiKey) })
return res.ok
Expand All @@ -31,7 +34,10 @@ async function openAiStyleValidate(base: string, apiKey?: string): Promise<boole
}

/** List models from an OpenAI-style `GET {base}/models` (`data[].id`). */
async function openAiStyleList(base: string, apiKey?: string): Promise<string[]> {
async function openAiStyleList(
base: string,
apiKey?: string,
): Promise<string[]> {
try {
const res = await fetch(`${base}/models`, { headers: bearer(apiKey) })
if (!res.ok) return []
Expand Down Expand Up @@ -126,6 +132,14 @@ export const anthropicBinding: AiSdkBinding = {
if (config.effort) anthropic.effort = config.effort
return Object.keys(anthropic).length ? { anthropic } : undefined
},
async providerTools(config: FullConfig) {
if (!config.enableWebSearch) return undefined
// Native `web_search_20250305` server tool: Anthropic runs the search
// itself and folds the results back into the same turn, so this never
// needs DEVS' own tool-execution loop (filtered out via `providerExecuted`).
const { anthropic } = await import('@ai-sdk/anthropic')
return { web_search: anthropic.tools.webSearch_20250305({ maxUses: 5 }) }
},
async validateApiKey(apiKey) {
// Validate on the endpoint the key is actually used with (`/v1/messages`,
// which honours the browser-access header). What matters is whether *auth*
Expand Down Expand Up @@ -224,7 +238,9 @@ export function makeCompatBinding(opts: CompatOptions): AiSdkBinding {
defaultModel: opts.defaultModel,
async createModel(config: AiSdkModelConfig) {
const baseURL = resolveNormalized(config)
const { createOpenAICompatible } = await import('@ai-sdk/openai-compatible')
const { createOpenAICompatible } = await import(
'@ai-sdk/openai-compatible'
)
const provider = createOpenAICompatible({
name: opts.name,
baseURL,
Expand Down Expand Up @@ -271,7 +287,9 @@ export const ollamaBinding = makeCompatBinding({
resolveBase: (c) => c.baseUrl || OLLAMA_DEFAULT_HOST,
// Ollama lists installed models via its native /api/tags endpoint.
list: async (config) => {
const host = trimTrailingSlash(ensureAbsolute(config?.baseUrl || OLLAMA_DEFAULT_HOST))
const host = trimTrailingSlash(
ensureAbsolute(config?.baseUrl || OLLAMA_DEFAULT_HOST),
)
try {
const res = await fetch(`${host}/api/tags`)
if (!res.ok) return []
Expand All @@ -284,7 +302,8 @@ export const ollamaBinding = makeCompatBinding({
validate: async (apiKey, baseUrl) => {
const host = trimTrailingSlash(
ensureAbsolute(
baseUrl || (apiKey && apiKey !== 'ollama-no-key' ? apiKey : OLLAMA_DEFAULT_HOST),
baseUrl ||
(apiKey && apiKey !== 'ollama-no-key' ? apiKey : OLLAMA_DEFAULT_HOST),
),
)
try {
Expand Down
Loading
Loading