Files
windmill/frontend/src/lib/components/copilot/lib.ts
T
hugocasaandClaude Opus 5 68fc7825bb fix: refresh AI provider model defaults and capability metadata (#10690)
* fix: refresh AI provider model defaults and capability metadata

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: send explicit thinking disable for Claude and cap Opus 4.1 output

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: resolve mistral-medium-latest window and OpenRouter Claude 5 off

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: cover au. bedrock geo and Fable 5 caching, revert unverified mistral ladder

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: scope the Anthropic explicit disable to models that think by default

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: translate the reasoning off sentinel on the backend Anthropic path

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: translate the reasoning off sentinel on the Bedrock Converse path

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: share the reasoning off sentinel and make its translation testable

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 18:41:40 +02:00

1510 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { AIProvider, AIProviderModel } from '$lib/gen'
import { workspaceStore, type DBSchema, type SQLSchema } from '$lib/stores'
import type { IntrospectionQuery } from 'graphql'
import OpenAI from 'openai'
import type {
ChatCompletionChunk,
ChatCompletionCreateParams,
ChatCompletionCreateParamsNonStreaming,
ChatCompletionCreateParamsStreaming,
ChatCompletionMessageFunctionToolCall,
ChatCompletionMessageParam
} from 'openai/resources/index.mjs'
import Anthropic from '@anthropic-ai/sdk'
import { get, type Writable } from 'svelte/store'
import { OpenAPI, ResourceService, type Script } from '../../gen'
import { EDIT_CONFIG, FIX_CONFIG, GEN_CONFIG } from './prompts'
import {
requiresMaxCompletionTokens,
usesAnthropicMessagesApi,
usesOpenRouterPromptCaching
} from './modelConfig'
import { applyReasoningToConfig } from './reasoningRegistry'
import { formatResourceTypes } from './utils'
import {
appendPendingToolImages,
processToolCall,
queuedToolStatus,
type Tool,
type ToolCallbacks
} from './chat/shared'
import { hasValidToolCallArguments } from './chat/toolCallArguments'
import {
getNonStreamingOpenAIResponsesCompletion,
getOpenAIResponsesCompletionStream
} from './chat/openai-responses'
import { convertOpenAIToAnthropicMessages } from './chat/anthropic'
import type { Stream } from 'openai/core/streaming.mjs'
import { generateRandomString } from '$lib/utils'
import { copilotInfo, getCurrentModel, getMetadataModel } from '$lib/aiStore'
import {
emptyChatTokenUsage,
openAICompletionsUsageToChatTokenUsage,
type ChatTokenUsage
} from './chat/tokenUsage'
import {
buildAssistantTextMessage,
buildAssistantToolCallMessage,
splitContentDelta,
getReasoningContentDelta
} from './chat/openaiReasoning'
import { parseFimCompletionChoice } from './fim'
export const SUPPORTED_LANGUAGES = new Set(Object.keys(GEN_CONFIG.prompts))
interface AIProviderDetails {
label: string
defaultModels: string[]
}
// The first entry is what a new workspace is created with (see
// CreateWorkspaceInner), so each list leads with the balanced tier rather than
// the frontier model. The gpt-5 family is deprecated (retires 2026-12-11) but
// still served, so it stays in the list below the 5.6 models.
const OPENAI_MODELS = [
'gpt-5.6-terra',
'gpt-5.6-sol',
'gpt-5.6-luna',
'gpt-5',
'gpt-5-mini',
'gpt-4o',
'gpt-4o-mini',
'o4-mini',
'o3'
]
export const AI_PROVIDERS: Record<AIProvider, AIProviderDetails> = {
openai: {
label: 'OpenAI',
defaultModels: OPENAI_MODELS
},
anthropic: {
label: 'Anthropic',
defaultModels: ['claude-sonnet-5', 'claude-opus-5', 'claude-opus-4-8', 'claude-haiku-4-5']
},
googleai: {
label: 'Google AI',
defaultModels: [
'gemini-3.6-flash',
'gemini-3.5-flash',
'gemini-3.1-pro',
'gemini-3.5-flash-lite',
'gemini-3.1-flash-lite',
'gemini-2.5-pro',
'gemini-2.5-flash'
]
},
azure_openai: {
label: 'Azure OpenAI',
defaultModels: OPENAI_MODELS
},
azure_foundry: {
label: 'Azure AI Foundry',
defaultModels: [
'gpt-5.6-terra',
'gpt-5.6-sol',
'claude-sonnet-5',
'claude-opus-5',
'DeepSeek-V4-Pro',
'Llama-3.3-70B-Instruct',
'Phi-4'
]
},
mistral: {
label: 'Mistral',
defaultModels: ['mistral-medium-latest', 'codestral-latest']
},
deepseek: {
label: 'DeepSeek',
defaultModels: ['deepseek-v4-pro', 'deepseek-v4-flash']
},
groq: {
label: 'Groq',
defaultModels: ['openai/gpt-oss-120b', 'openai/gpt-oss-20b']
},
openrouter: {
label: 'OpenRouter',
defaultModels: ['meta-llama/llama-3.2-3b-instruct:free']
},
togetherai: {
label: 'Together AI',
defaultModels: ['meta-llama/Llama-3.3-70B-Instruct-Turbo']
},
aws_bedrock: {
label: 'AWS Bedrock',
defaultModels: [
'global.anthropic.claude-sonnet-5',
'global.anthropic.claude-opus-5',
'global.anthropic.claude-haiku-4-5-20251001-v1:0'
]
},
customai: {
label: 'Custom AI',
defaultModels: []
}
}
export interface ModelResponse {
id: string
object: string
created: number
owned_by: string
lifecycle_status: string
capabilities: {
completion: boolean
chat_completion: boolean
}
}
export async function fetchAvailableModels(
resourcePath: string,
workspace: string,
provider: AIProvider,
signal?: AbortSignal
): Promise<string[]> {
// Handle AWS Bedrock separately (needs both foundation-models and inference-profiles)
if (provider === 'aws_bedrock') {
const headers = {
'X-Resource-Path': resourcePath,
'X-Provider': provider
}
// Fetch both foundation models and inference profiles
const [foundationModelsResp, inferenceProfilesResp] = await Promise.all([
fetch(`${location.origin}${OpenAPI.BASE}/w/${workspace}/ai/proxy/foundation-models`, {
signal,
headers
}),
fetch(`${location.origin}${OpenAPI.BASE}/w/${workspace}/ai/proxy/inference-profiles`, {
signal,
headers
})
])
if (!foundationModelsResp.ok) {
console.error('Failed to fetch foundation models', foundationModelsResp)
throw new Error('Failed to fetch foundation models for AWS Bedrock')
}
const foundationModelsData = (await foundationModelsResp.json()) as {
modelSummaries: Array<{
modelId: string
modelArn: string
inputModalities: string[]
outputModalities: string[]
inferenceTypesSupported: string[]
}>
}
// Inference profiles fetch might fail in some regions/accounts
let inferenceProfiles: Array<{
inferenceProfileId: string
models: Array<{ modelArn: string }>
}> = []
if (inferenceProfilesResp.ok) {
const inferenceProfilesData = (await inferenceProfilesResp.json()) as {
inferenceProfileSummaries: Array<{
inferenceProfileId: string
models: Array<{ modelArn: string }>
}>
}
inferenceProfiles = inferenceProfilesData.inferenceProfileSummaries || []
} else {
console.warn('Failed to fetch inference profiles, will use direct model IDs only')
}
// Filter to TEXT-capable models
const textModels = foundationModelsData.modelSummaries.filter(
(m) => m.inputModalities?.includes('TEXT') && m.outputModalities?.includes('TEXT')
)
const onDemandModels = textModels
.filter(
(model) =>
model.inferenceTypesSupported?.includes('ON_DEMAND') &&
!model.inferenceTypesSupported?.includes('INFERENCE_PROFILE')
)
.map((model) => model.modelId)
const inferenceModels = inferenceProfiles.map((profile) => profile.inferenceProfileId)
const modelIds = [...onDemandModels, ...inferenceModels]
// Sort by default models
const defaultModels = AI_PROVIDERS[provider]?.defaultModels || []
return modelIds.sort((a, b) => {
const aInDefault = defaultModels.includes(a)
const bInDefault = defaultModels.includes(b)
if (aInDefault && !bInDefault) return -1
if (!aInDefault && bInDefault) return 1
return 0
})
}
// Standard provider handling
const endpoint = 'models'
const models = await fetch(
`${location.origin}${OpenAPI.BASE}/w/${workspace}/ai/proxy/${endpoint}`,
{
signal,
headers: {
'X-Resource-Path': resourcePath,
'X-Provider': provider,
...(provider === 'anthropic' ? { 'anthropic-version': '2023-06-01' } : {})
}
}
)
if (!models.ok) {
console.error('Failed to fetch models for provider', provider, models)
throw new Error(`Failed to fetch models for provider ${provider}`)
}
const data = (await models.json()) as { data: ModelResponse[] }
if (data.data.length > 0) {
const sortFunc = (provider: AIProvider) => (a: string, b: string) => {
// First prioritize models in defaultModels array
const defaultModels = AI_PROVIDERS[provider]?.defaultModels || []
const aInDefault = defaultModels.includes(a)
const bInDefault = defaultModels.includes(b)
if (aInDefault && !bInDefault) return -1
if (!aInDefault && bInDefault) return 1
return 0
}
switch (provider) {
case 'openai':
return data.data
.filter(
(m) => m.id.startsWith('gpt-') || m.id.startsWith('o') || m.id.startsWith('codex')
)
.map((m) => m.id)
.sort(sortFunc(provider))
case 'azure_openai':
return data.data
.filter(
(m) =>
(m.id.startsWith('gpt-') || m.id.startsWith('o') || m.id.startsWith('codex')) &&
m.lifecycle_status !== 'deprecated'
)
.map((m) => m.id)
.sort(sortFunc(provider))
case 'googleai':
return data.data.map((m) => m.id.split('/')[1]).sort(sortFunc(provider))
default:
return data.data.map((m) => m.id).sort(sortFunc(provider))
}
}
return data?.data.map((m) => m.id) ?? []
}
export function getModelMaxTokens(provider: AIProvider, model: string) {
if (model.includes('gpt-5')) {
return 128000
} else if (
(provider === 'azure_openai' || provider === 'openai' || provider === 'azure_foundry') &&
model.startsWith('o')
) {
return 100000
} else if (
// Raising this further would also raise the worst case of the
// non-streaming completion path, which the Anthropic SDK refuses once
// the request could run past ~10 minutes.
model.includes('claude-sonnet') ||
model.includes('claude-haiku') ||
model.includes('claude-fable') ||
model.includes('claude-mythos') ||
// Opus only from 4.5 on. Opus 4.1 and older cap at 32K and fall through
// to the row below. Dots are normalized because OpenRouter writes
// `anthropic/claude-opus-4.5` where Anthropic writes `claude-opus-4-5`.
/claude-opus-(4-(5|6|7|8)|5)(?!\d)/.test(model.replace(/\./g, '-')) ||
model.includes('gemini-2.5') ||
model.includes('gemini-3')
) {
return 64000
} else if (model.includes('gpt-4.1')) {
return 32768
} else if (model.includes('claude-opus')) {
return 32000
} else if (model.includes('gpt-4o') || model.includes('codestral')) {
return 16384
} else if (model.includes('gpt-4-turbo') || model.includes('gpt-3.5')) {
return 4096
}
return 8192
}
// Resolves the completion token cap for a model: the workspace's per-model
// override when set, otherwise the built-in default. Shared by the OpenAI and
// Anthropic request paths so both honor the same limit. `cap` bounds the result
// (used by short metadata completions, see METADATA_MAX_TOKENS) — a hard ceiling
// that wins over both the workspace override and the default.
function resolveMaxTokens(modelProvider: AIProviderModel, cap?: number): number {
const defaultMaxTokens = getModelMaxTokens(modelProvider.provider, modelProvider.model)
const modelKey = `${modelProvider.provider}:${modelProvider.model}`
let customMaxTokensStore: Record<string, number> | undefined
try {
customMaxTokensStore = get(copilotInfo)?.maxTokensPerModel
} catch {
// copilotInfo store may not be initialized in vitest
}
const resolved = customMaxTokensStore?.[modelKey] ?? defaultMaxTokens
return cap !== undefined ? Math.min(resolved, cap) : resolved
}
// Token cap for metadata completions (session titles, cron/predicate/step-input
// generation). These outputs are short, and the Anthropic SDK refuses a
// non-streaming request whose max_tokens implies a >10-minute worst case
// (60min × max_tokens / 128000): the model defaults (sonnet/haiku 64000, opus
// 32000) all trip it. Capping keeps every provider's metadata call non-streaming.
export const METADATA_MAX_TOKENS = 4096
function getModelSpecificConfig(
modelProvider: AIProviderModel,
tools?: OpenAI.Chat.Completions.ChatCompletionTool[],
maxTokensCap?: number
) {
const maxTokens = resolveMaxTokens(modelProvider, maxTokensCap)
if (
(modelProvider.provider === 'openai' ||
modelProvider.provider === 'azure_openai' ||
modelProvider.provider === 'azure_foundry') &&
requiresMaxCompletionTokens(modelProvider.model)
) {
return {
model: modelProvider.model,
...(tools && tools.length > 0 ? { tools } : {}),
max_completion_tokens: maxTokens
}
} else {
return {
model: modelProvider.model,
...(tools && tools.length > 0 ? { tools } : {}),
max_tokens: maxTokens
}
}
}
const EPHEMERAL_CACHE_CONTROL = { type: 'ephemeral' } as const
// Returns a copy carrying a cache breakpoint on its last text part. The message objects
// are the live chat history and are also replayed through the Anthropic path, so they
// must not be mutated. Parts other than text (images) are left alone: only text blocks
// are documented to carry the field over the OpenAI-compatible surface.
function withCacheBreakpoint(message: ChatCompletionMessageParam): ChatCompletionMessageParam {
const content = message.content
if (typeof content === 'string') {
if (!content) return message
return {
...message,
// `cache_control` is an OpenRouter passthrough field, absent from the OpenAI types.
content: [{ type: 'text', text: content, cache_control: EPHEMERAL_CACHE_CONTROL } as any]
} as ChatCompletionMessageParam
}
if (!Array.isArray(content)) return message
let last = -1
for (let i = content.length - 1; i >= 0; i--) {
if ((content[i] as { type?: string }).type === 'text') {
last = i
break
}
}
if (last < 0) return message
return {
...message,
content: content.map((part, i) =>
i === last ? { ...part, cache_control: EPHEMERAL_CACHE_CONTROL } : part
)
} as ChatCompletionMessageParam
}
// Anthropic caches the whole prefix up to a breakpoint, ordered tools -> system ->
// messages, so the one on the system message also covers the tool definitions, by far
// the largest static block of a chat request. The second covers the settled conversation
// up to the newest user turn. Tool results appended after it inside an agent loop stay
// outside the cached prefix: reaching those needs a breakpoint on a `tool` message, and
// OpenRouter documents the field on text content blocks only. Two of a budget of four.
function withOpenRouterCacheBreakpoints(
messages: ChatCompletionMessageParam[]
): ChatCompletionMessageParam[] {
const prepared = [...messages]
const system = prepared.findIndex((m) => m.role === 'system')
if (system >= 0) prepared[system] = withCacheBreakpoint(prepared[system])
for (let i = prepared.length - 1; i > system; i--) {
if (prepared[i].role === 'user') {
prepared[i] = withCacheBreakpoint(prepared[i])
break
}
}
return prepared
}
function prepareMessages(
aiProvider: AIProvider,
messages: ChatCompletionMessageParam[],
{ model, promptCaching }: { model: string; promptCaching?: boolean }
) {
if (promptCaching && usesOpenRouterPromptCaching(aiProvider, model)) {
return withOpenRouterCacheBreakpoints(messages)
}
switch (aiProvider) {
case 'googleai':
// system messages are not supported by gemini
const systemMessage = messages.find((m) => m.role === 'system')
if (systemMessage) {
messages.shift()
const startMessages: ChatCompletionMessageParam[] = [
{
role: 'user',
content: 'System prompt: ' + (systemMessage.content as string)
},
{
role: 'assistant',
content: 'Understood'
}
]
messages = [...startMessages, ...messages]
}
return messages
default:
return messages
}
}
const DEFAULT_COMPLETION_CONFIG: ChatCompletionCreateParams = {
model: '',
messages: []
}
export const PROVIDER_COMPLETION_CONFIG_MAP: Record<AIProvider, ChatCompletionCreateParams> = {
openai: DEFAULT_COMPLETION_CONFIG,
azure_openai: DEFAULT_COMPLETION_CONFIG,
azure_foundry: DEFAULT_COMPLETION_CONFIG,
groq: DEFAULT_COMPLETION_CONFIG,
openrouter: DEFAULT_COMPLETION_CONFIG,
togetherai: DEFAULT_COMPLETION_CONFIG,
deepseek: DEFAULT_COMPLETION_CONFIG,
customai: DEFAULT_COMPLETION_CONFIG,
googleai: DEFAULT_COMPLETION_CONFIG,
mistral: DEFAULT_COMPLETION_CONFIG,
anthropic: DEFAULT_COMPLETION_CONFIG,
aws_bedrock: DEFAULT_COMPLETION_CONFIG
} as const
export function getAiProxyBaseURL(workspace?: string): string {
return workspace
? `${location.origin}${OpenAPI.BASE}/w/${workspace}/ai/proxy`
: `${location.origin}${OpenAPI.BASE}/ai/proxy`
}
export function createOpenAIProxyClient(baseURL: string): OpenAI {
return new OpenAI({
baseURL,
apiKey: 'fake-key',
defaultHeaders: {
Authorization: OpenAPI.TOKEN ? `Bearer ${OpenAPI.TOKEN}` : ''
},
dangerouslyAllowBrowser: true
})
}
export function createAnthropicProxyClient(baseURL: string): Anthropic {
return new Anthropic({
baseURL,
apiKey: 'fake-key',
dangerouslyAllowBrowser: true,
defaultHeaders: OpenAPI.TOKEN ? { Authorization: `Bearer ${OpenAPI.TOKEN}` } : undefined
})
}
class WorkspacedAIClients {
private openaiClient: OpenAI | undefined
private anthropicClient: Anthropic | undefined
init(workspace: string) {
this.openaiClient = this.createOpenaiClient(workspace)
this.anthropicClient = this.createAnthropicClient(workspace)
}
createOpenaiClient(workspace: string): OpenAI {
return createOpenAIProxyClient(getAiProxyBaseURL(workspace))
}
createAnthropicClient(workspace: string): Anthropic {
return createAnthropicProxyClient(getAiProxyBaseURL(workspace))
}
getOpenaiClient() {
if (!this.openaiClient) {
throw new Error('OpenAI not initialized')
}
return this.openaiClient
}
getAnthropicClient() {
if (!this.anthropicClient) {
throw new Error('Anthropic not initialized')
}
return this.anthropicClient
}
}
export const workspaceAIClients = new WorkspacedAIClients()
export async function testKey({
apiKey,
workspace,
resourcePath,
model,
abortController,
messages,
aiProvider
}: {
apiKey?: string
workspace?: string
resourcePath?: string
model: string | undefined
messages: ChatCompletionMessageParam[]
abortController: AbortController
aiProvider: AIProvider
}) {
if (!apiKey && !resourcePath) {
throw new Error('API key or resource path is required')
}
const modelToTest = model ?? AI_PROVIDERS[aiProvider].defaultModels[0]
if (!modelToTest) {
throw new Error('Missing a model to test')
}
// getNonStreamingCompletion routes Anthropic-Messages-API models (native
// Anthropic and Claude on Azure Foundry) through the Anthropic SDK and
// everything else through OpenAI chat completions, so the test exercises the
// same request shape the feature actually sends. The cap keeps max_tokens
// under the Anthropic SDK's non-streaming pre-flight limit (~21k tokens),
// which would otherwise reject the request before it is sent.
await getNonStreamingCompletion(messages, abortController, {
apiKey,
workspace,
resourcePath,
forceModelProvider: {
model: modelToTest,
provider: aiProvider
},
maxTokensCap: METADATA_MAX_TOKENS
})
}
// Providers served through the Anthropic Messages API (native Anthropic, and
// Claude deployments on Azure Foundry) require the Anthropic SDK request shape:
// OpenAI chat-completions requests fail against them because the proxy forwards
// the body verbatim and, for Foundry, rewrites the URL to the /anthropic/v1
// surface that only serves /messages. This centralizes the client/header/message
// setup so every completion entry point routes them the same way the chat does.
interface AnthropicCompletionParams {
messages: ChatCompletionMessageParam[]
modelProvider: AIProviderModel
abortController: AbortController
apiKey?: string
workspace?: string
resourcePath?: string
maxTokensCap?: number
}
function buildAnthropicProxyRequest({
messages,
modelProvider,
apiKey,
workspace,
resourcePath,
maxTokensCap
}: Omit<AnthropicCompletionParams, 'abortController'>) {
const { system, messages: anthropicMessages } = convertOpenAIToAnthropicMessages(messages)
// X-Provider must be the real provider (e.g. azure_foundry) so the backend
// resolves the right credentials and Anthropic URL.
const headers: Record<string, string> = {
'X-Provider': modelProvider.provider,
'anthropic-version': '2023-06-01'
}
if (resourcePath) {
headers['X-Resource-Path'] = resourcePath
} else if (apiKey) {
headers['X-API-Key'] = apiKey
}
const client = apiKey
? createAnthropicProxyClient(getAiProxyBaseURL())
: workspace
? workspaceAIClients.createAnthropicClient(workspace)
: workspaceAIClients.getAnthropicClient()
const body = {
model: modelProvider.model,
max_tokens: resolveMaxTokens(modelProvider, maxTokensCap),
messages: anthropicMessages,
...(system && { system })
}
return { client, headers, body }
}
async function getAnthropicNonStreamingCompletion({
abortController,
...params
}: AnthropicCompletionParams): Promise<string> {
const { client, headers, body } = buildAnthropicProxyRequest(params)
const message = await client.messages.create(body, {
signal: abortController.signal,
headers
})
return message.content.map((block) => (block.type === 'text' ? block.text : '')).join('')
}
// Adapts an Anthropic Messages stream into the OpenAI ChatCompletionChunk shape
// the completion consumers already iterate, so they need no Anthropic-specific
// handling. Only text deltas are surfaced (these paths don't use tool calls).
function getAnthropicStreamingCompletion({
abortController,
...params
}: AnthropicCompletionParams): Stream<ChatCompletionChunk> {
const { client, headers, body } = buildAnthropicProxyRequest(params)
const stream = client.messages.stream(body, {
signal: abortController.signal,
headers
})
async function* toOpenAIChunks(): AsyncGenerator<ChatCompletionChunk> {
for await (const event of stream) {
if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
yield {
id: '',
object: 'chat.completion.chunk',
created: 0,
model: params.modelProvider.model,
choices: [{ index: 0, delta: { content: event.delta.text }, finish_reason: null }]
}
}
}
}
return toOpenAIChunks() as unknown as Stream<ChatCompletionChunk>
}
interface BaseOptions {
language: Script['language'] | 'frontend' | 'transformer'
dbSchema: DBSchema | undefined
workspace: string
}
interface ScriptGenerationOptions extends BaseOptions {
description: string
type: 'gen'
}
interface EditScriptOptions extends BaseOptions {
description: string
code: string
type: 'edit'
}
interface FixScriptOpions extends BaseOptions {
code: string
error: string
type: 'fix'
}
type CopilotOptions = ScriptGenerationOptions | EditScriptOptions | FixScriptOpions
async function getResourceTypes(scriptOptions: CopilotOptions) {
const elems =
scriptOptions.type === 'gen' || scriptOptions.type === 'edit' ? [scriptOptions.description] : []
if (scriptOptions.type === 'edit' || scriptOptions.type === 'fix') {
const { code } = scriptOptions
const mainSig =
scriptOptions.language === 'python3'
? code.match(/def main\((.*?)\)/s)
: code.match(/function main\((.*?)\)/s)
if (mainSig) {
elems.push(mainSig[1])
}
const matches = code.matchAll(/^(?:type|class) ([a-zA-Z0-9_]+)/gm)
for (const match of matches) {
elems.push(match[1])
}
}
const resourceTypes = await ResourceService.queryResourceTypes({
workspace: scriptOptions.workspace,
text: elems.join(';'),
limit: 3
})
return resourceTypes
}
export async function addResourceTypes(scriptOptions: CopilotOptions, prompt: string) {
if (['deno', 'bun', 'nativets', 'python3', 'php'].includes(scriptOptions.language)) {
const resourceTypes = await getResourceTypes(scriptOptions)
const resourceTypesText = formatResourceTypes(
resourceTypes,
['deno', 'bun', 'nativets'].includes(scriptOptions.language)
? 'typescript'
: (scriptOptions.language as 'python3' | 'php')
)
prompt = prompt.replace('{resourceTypes}', resourceTypesText)
}
return prompt
}
export const MAX_SCHEMA_LENGTH = 100000 * 3.5
export function addThousandsSeparator(n: number) {
return n.toFixed().replace(/\B(?=(\d{3})+(?!\d))/g, "'")
}
export function stringifySchema(dbSchema: Omit<SQLSchema, 'stringified'>): string {
const { schema, lang } = dbSchema
let smallerSchema: {
[schemaKey: string]: {
[tableKey: string]: Array<[string, string, boolean, string?]>
}
} = {}
for (const schemaKey in schema) {
smallerSchema[schemaKey] = {}
for (const tableKey in schema[schemaKey]) {
smallerSchema[schemaKey][tableKey] = []
for (const colKey in schema[schemaKey][tableKey]) {
const col = schema[schemaKey][tableKey][colKey]
const p: [string, string, boolean, string?] = [colKey, col.type, col.required]
if (col.default) {
p.push(col.default)
}
smallerSchema[schemaKey][tableKey].push(p)
}
}
}
let finalSchema: typeof smallerSchema | (typeof smallerSchema)['schemaKey'] = smallerSchema
if (dbSchema.publicOnly) {
finalSchema = smallerSchema.public || smallerSchema.PUBLIC || smallerSchema.dbo || smallerSchema
} else if (lang === 'mysql' && Object.keys(smallerSchema).length === 1) {
finalSchema = smallerSchema[Object.keys(smallerSchema)[0]]
}
return JSON.stringify(finalSchema)
}
export async function stringifyGraphqlSchema(schema: unknown): Promise<string> {
const { buildClientSchema, printSchema } = await import('graphql')
return printSchema(buildClientSchema(schema as IntrospectionQuery))
}
function addDBSChema(scriptOptions: CopilotOptions, prompt: string) {
const { dbSchema, language } = scriptOptions
if (
dbSchema &&
['postgresql', 'mysql', 'snowflake', 'bigquery', 'mssql', 'graphql', 'oracledb'].includes(
language
) && // make sure we are using a SQL/query language
language === dbSchema.lang // make sure we are using the same language as the schema
) {
let { stringified } = dbSchema
if (dbSchema.lang === 'graphql') {
if (stringified.length > MAX_SCHEMA_LENGTH) {
stringified = stringified.slice(0, MAX_SCHEMA_LENGTH) + '...'
}
prompt = prompt + '\nHere is the GraphQL schema: <schema>\n' + stringified + '\n</schema>'
} else {
if (stringified.length > MAX_SCHEMA_LENGTH) {
stringified = stringified.slice(0, MAX_SCHEMA_LENGTH) + '...'
}
prompt =
prompt +
"\nHere's the database schema, each column is in the format [name, type, required, default?]: <dbschema>\n" +
stringified +
'\n</dbschema>'
}
}
return prompt
}
async function getPrompts(scriptOptions: CopilotOptions) {
const promptsConfig = PROMPTS_CONFIGS[scriptOptions.type]
let prompt = promptsConfig.prompts[scriptOptions.language].prompt
if (scriptOptions.type !== 'fix') {
prompt = prompt.replace('{description}', scriptOptions.description)
}
if (scriptOptions.type !== 'gen') {
prompt = prompt.replace('{code}', scriptOptions.code)
}
if (scriptOptions.type === 'fix') {
if (scriptOptions.language === 'frontend') {
throw new Error('Fixing frontend code is not supported')
}
prompt = prompt.replace('{error}', scriptOptions.error)
}
prompt = await addResourceTypes(scriptOptions, prompt)
prompt = addDBSChema(scriptOptions, prompt)
return { prompt, systemPrompt: promptsConfig.system }
}
const PROMPTS_CONFIGS = {
fix: FIX_CONFIG,
edit: EDIT_CONFIG,
gen: GEN_CONFIG
}
/**
* Whether a provider can use native web search automatically in the web chat.
* Azure OpenAI can expose Responses API `web_search` for some deployments, but
* it is subscription/admin controlled and routes data through Grounding with
* Bing, so do not silently enable it until there is explicit Azure-specific UI.
* Providers behind OpenAI-compatible/native-translation proxy paths have no
* forwardable native web-search tool.
*/
export function providerSupportsWebSearch(provider: AIProvider | undefined): boolean {
return provider === 'openai' || provider === 'anthropic'
}
export function getProviderAndCompletionConfig<K extends boolean>({
messages,
stream,
tools,
forceModelProvider,
maxTokensCap,
promptCaching
}: {
messages: ChatCompletionMessageParam[]
stream: K
tools?: OpenAI.Chat.Completions.ChatCompletionTool[]
forceModelProvider?: AIProviderModel
maxTokensCap?: number
// Opt-in: a cache write costs more than an uncached read, so it only pays off where
// the same prefix is sent again. True for the chat loop, false for one-shot calls.
promptCaching?: boolean
}): {
provider: AIProvider
config: K extends true
? ChatCompletionCreateParamsStreaming
: ChatCompletionCreateParamsNonStreaming
} {
const modelProvider = forceModelProvider ?? getCurrentModel()
const providerConfig = PROVIDER_COMPLETION_CONFIG_MAP[modelProvider.provider]
const processedMessages = prepareMessages(modelProvider.provider, messages, {
model: modelProvider.model,
promptCaching
})
return {
provider: modelProvider.provider,
config: {
...providerConfig,
...getModelSpecificConfig(modelProvider, tools, maxTokensCap),
messages: processedMessages,
stream
} as any
}
}
export async function getNonStreamingCompletion(
messages: ChatCompletionMessageParam[],
abortController: AbortController,
options?: {
apiKey?: string // testing API KEY using the global ai proxy
resourcePath?: string // testing resource path passed as a header to the backend proxy
workspace?: string // use a specific workspace proxy when testing a workspace resource
forceModelProvider?: AIProviderModel
maxTokensCap?: number // hard ceiling on output tokens (see METADATA_MAX_TOKENS)
}
) {
const modelProvider = options?.forceModelProvider ?? getCurrentModel()
if (usesAnthropicMessagesApi(modelProvider.provider, modelProvider.model)) {
return getAnthropicNonStreamingCompletion({
messages,
modelProvider,
abortController,
apiKey: options?.apiKey,
workspace: options?.workspace,
resourcePath: options?.resourcePath,
maxTokensCap: options?.maxTokensCap
})
}
let response: string | undefined = ''
const { provider, config } = getProviderAndCompletionConfig({
messages,
stream: false,
forceModelProvider: options?.forceModelProvider,
maxTokensCap: options?.maxTokensCap
})
// Use Responses API for OpenAI and Azure OpenAI
if (provider === 'openai' || provider === 'azure_openai') {
try {
const response = await getNonStreamingOpenAIResponsesCompletion(
messages,
abortController,
options
)
return response
} catch (error) {
console.error('Error using Responses API:', error)
}
}
const fetchOptions: {
signal: AbortSignal
headers: Record<string, string>
} = {
signal: abortController.signal,
headers: {
'X-Provider': provider
}
}
if (options?.resourcePath) {
fetchOptions.headers = {
...fetchOptions.headers,
'X-Resource-Path': options.resourcePath
}
} else if (options?.apiKey) {
if (provider === 'customai') {
throw new Error('Cannot test API key for Custom AI, only resource path is supported')
}
fetchOptions.headers = {
...fetchOptions.headers,
'X-API-Key': options.apiKey
}
}
const openaiClient = options?.apiKey
? createOpenAIProxyClient(getAiProxyBaseURL())
: options?.workspace
? workspaceAIClients.createOpenaiClient(options.workspace)
: workspaceAIClients.getOpenaiClient()
const completion = await openaiClient.chat.completions.create(config, fetchOptions)
response = completion.choices?.[0]?.message.content || ''
return response
}
export async function getNonStreamingMetadataCompletion(
messages: ChatCompletionMessageParam[],
abortController: AbortController
) {
return getNonStreamingCompletion(messages, abortController, {
forceModelProvider: getMetadataModel(),
maxTokensCap: METADATA_MAX_TOKENS
})
}
export const FIM_MAX_TOKENS = 256
const FIM_MAX_LINES = 8
export async function getFimCompletion(
prompt: string,
suffix: string,
providerModel: AIProviderModel,
abortController: AbortController
): Promise<string | undefined> {
// The Anthropic Messages API has no fill-in-the-middle endpoint, and Foundry
// Claude deployments don't expose the OpenAI-compatible completions surface the
// FIM proxy targets. Skip autocomplete for these models rather than issuing a
// request that can't succeed.
if (usesAnthropicMessagesApi(providerModel.provider, providerModel.model)) {
return undefined
}
const fetchOptions: {
signal: AbortSignal
headers: Record<string, string>
} = {
signal: abortController.signal,
headers: {
'X-Provider': providerModel.provider
}
}
const workspace = get(workspaceStore)
const response = await fetch(
`${location.origin}${OpenAPI.BASE}/w/${workspace}/ai/proxy/fim/completions`,
{
method: 'POST',
body: JSON.stringify({
model: providerModel.model,
temperature: 0,
prompt,
suffix,
stop: ['\n\n'],
max_tokens: FIM_MAX_TOKENS
}),
...fetchOptions
}
)
const body = await response.json()
const choice = parseFimCompletionChoice(body, providerModel.provider)
if (choice?.content !== undefined) {
let lines = choice.content.split('\n')
// If finish_reason is 'length', remove the last line
if (choice.finish_reason === 'length') {
if (lines.length > 1) {
lines = lines.slice(0, -1)
} else {
lines = []
}
}
lines = lines.slice(0, FIM_MAX_LINES)
return lines.join('\n')
} else {
return undefined
}
}
export async function getCompletion(
messages: ChatCompletionMessageParam[],
abortController: AbortController,
tools?: OpenAI.Chat.Completions.ChatCompletionTool[],
options?: {
forceCompletions?: boolean
forceModelProvider?: AIProviderModel
openaiClient?: OpenAI
reasoningEffort?: string
promptCaching?: boolean
}
): Promise<Stream<ChatCompletionChunk>> {
const modelProvider = options?.forceModelProvider ?? getCurrentModel()
if (usesAnthropicMessagesApi(modelProvider.provider, modelProvider.model)) {
return getAnthropicStreamingCompletion({ messages, modelProvider, abortController })
}
const { provider, config } = getProviderAndCompletionConfig({
messages,
stream: true,
tools,
forceModelProvider: options?.forceModelProvider,
promptCaching: options?.promptCaching
})
// Use Responses API for OpenAI and Azure OpenAI
if ((provider === 'openai' || provider === 'azure_openai') && !options?.forceCompletions) {
try {
const stream = getOpenAIResponsesCompletionStream(messages, abortController, tools, {
forceModelProvider: options?.forceModelProvider,
openaiClient: options?.openaiClient,
reasoningEffort: options?.reasoningEffort
}) as any
return stream
} catch (error) {
console.error('Error using Responses API:', error)
}
}
// Use Completions API for other providers
const client = options?.openaiClient ?? workspaceAIClients.getOpenaiClient()
const completionConfig = applyReasoningToConfig(
(provider === 'openai' ||
provider === 'azure_openai' ||
provider === 'azure_foundry' ||
provider === 'googleai') &&
config.stream
? {
...config,
stream_options: {
...(config.stream_options ?? {}),
include_usage: true
}
}
: config,
provider === 'deepseek' ? 'deepseek' : provider === 'mistral' ? 'mistral' : 'completions',
options?.reasoningEffort
)
const completion = client.chat.completions.create(completionConfig, {
signal: abortController.signal,
headers: {
'X-Provider': provider
}
})
return completion
}
function extractFirstJSON(str: string) {
let depth = 0,
i = 0
for (; i < str.length; i++) {
if (str[i] === '{') depth++
else if (str[i] === '}' && --depth === 0) break
}
return str.slice(0, i + 1)
}
export async function parseOpenAICompletion(
completion: Stream<ChatCompletionChunk>,
callbacks: ToolCallbacks & {
onNewToken: (token: string) => void
onMessageEnd: () => void
},
messages: ChatCompletionMessageParam[],
addedMessages: ChatCompletionMessageParam[],
tools: Tool<any>[],
helpers: any,
_abortController?: AbortController, // unused, for signature compatibility with parseAnthropicCompletion
options?: { workspace?: string; provider?: string }
): Promise<{ shouldContinue: boolean; tokenUsage: ChatTokenUsage }> {
const finalToolCalls: Record<number, ChatCompletionChunk.Choice.Delta.ToolCall> = {}
// The tool call currently receiving argument deltas; when the stream moves on
// to the next call, the previous one is demoted to queued.
let streamingToolCallId: string | undefined = undefined
let malformedFunctionCallError = false
let tokenUsage = emptyChatTokenUsage()
let answer = ''
let assistantContent = ''
let reasoningContent = ''
let hasReasoningContent = false
for await (const chunk of completion) {
if ('usage' in chunk && chunk.usage) {
tokenUsage = openAICompletionsUsageToChatTokenUsage(chunk.usage)
}
if (!('choices' in chunk && chunk.choices.length > 0 && 'delta' in chunk.choices[0])) {
continue
}
const c = chunk as ChatCompletionChunk
const choice = c.choices[0]
const delta = choice.delta
// Check for malformed function call error (e.g. from Gemini models)
const finishReason = choice.finish_reason
if (
finishReason &&
typeof finishReason === 'string' &&
finishReason.includes('MALFORMED_FUNCTION_CALL')
) {
malformedFunctionCallError = true
}
// Mistral nests reasoning inside structured content parts; split them out
// so a content delta never leaks "[object Object]" into the answer.
const structured = splitContentDelta(delta.content)
const reasoningDelta = getReasoningContentDelta(delta)
const reasoningText =
(typeof reasoningDelta === 'string' ? reasoningDelta : '') + structured.reasoning
if (typeof reasoningDelta === 'string' || structured.reasoning) {
hasReasoningContent = true
reasoningContent += reasoningText
callbacks.onReasoningDelta?.(reasoningText)
}
const contentDelta = structured.text
if (contentDelta) {
answer += contentDelta
assistantContent += contentDelta
callbacks.onNewToken(contentDelta)
}
const toolCalls = delta.tool_calls || []
if (toolCalls.length > 0 && answer) {
// if tool calls are present but we have some textual content already, we need to display it to the user first
callbacks.onMessageEnd()
answer = ''
}
for (let i = 0; i < toolCalls.length; i++) {
const toolCall = toolCalls[i]
// Gemini models are missing the index field
if (
toolCall.index === undefined ||
(typeof toolCall.index === 'string' && toolCall.index === '')
) {
toolCall.index = i
}
// Gemini models are missing the id field
if (toolCall.id === undefined || (typeof toolCall.id === 'string' && toolCall.id === '')) {
toolCall.id = generateRandomString()
}
const { index } = toolCall
let finalToolCall = finalToolCalls[index]
if (!finalToolCall) {
finalToolCalls[index] = toolCall
} else {
if (toolCall.function?.arguments) {
if (!finalToolCall.function) {
finalToolCall.function = toolCall.function
} else {
finalToolCall.function.arguments =
(finalToolCall.function.arguments ?? '') + toolCall.function.arguments
// Make sure we only have one JSON object, else for Gemini models it sometimes results in two JSON objects
finalToolCall.function.arguments = extractFirstJSON(
finalToolCall.function.arguments || '{}'
)
}
}
}
finalToolCall = finalToolCalls[index]
if (finalToolCall?.function) {
const {
function: { name: funcName },
id: toolCallId
} = finalToolCall
if (funcName && toolCallId) {
if (streamingToolCallId !== undefined && streamingToolCallId !== toolCallId) {
const previous = Object.values(finalToolCalls).find(
(tc) => tc.id === streamingToolCallId
)
callbacks.setToolStatus(
streamingToolCallId,
queuedToolStatus(tools, previous?.function?.name ?? '', previous?.function?.arguments)
)
}
streamingToolCallId = toolCallId
const tool = tools.find((t) => t.def.function.name === funcName)
const shouldStream = tool?.streamArguments ?? false
const accumulatedArgs = finalToolCall.function.arguments
let parameters: any = undefined
if (accumulatedArgs) {
try {
parameters = JSON.parse(accumulatedArgs)
} catch {
parameters = accumulatedArgs
}
}
// Display tool call with streaming parameters if enabled. isQueued is
// cleared explicitly: a provider may interleave deltas of parallel
// calls, re-promoting a call that was already demoted to queued.
callbacks.setToolStatus(toolCallId, {
isLoading: true,
isQueued: false,
content: tool?.streamingLabel ?? `Preparing ${funcName}...`,
toolName: funcName,
isStreamingArguments: shouldStream,
showFade: tool?.showFade,
showDetails: tool?.showDetails,
autoCollapseDetails: tool?.autoCollapseDetails,
parameters: parameters
})
}
}
}
}
const toolCalls = Object.values(finalToolCalls).filter(
(toolCall) => toolCall.id !== undefined && toolCall.function?.arguments !== undefined
) as ChatCompletionMessageFunctionToolCall[]
if (answer && toolCalls.length === 0) {
const toAdd = buildAssistantTextMessage(answer)
addedMessages.push(toAdd)
messages.push(toAdd)
}
callbacks.onMessageEnd()
// Stream over: every parsed call is queued until its turn in processToolCall.
for (const toolCall of Object.values(finalToolCalls)) {
if (toolCall.id) {
callbacks.setToolStatus(
toolCall.id,
queuedToolStatus(tools, toolCall.function?.name ?? '', toolCall.function?.arguments)
)
}
}
if (toolCalls.length > 0) {
const invalidToolCallIds = new Set(
toolCalls.filter((t) => !hasValidToolCallArguments(t.function.arguments)).map((t) => t.id)
)
const normalizedToolCalls = toolCalls.map((t) => ({
...t,
function: {
...t.function,
arguments: invalidToolCallIds.has(t.id) ? '{}' : t.function.arguments || '{}'
}
}))
const toAdd = buildAssistantToolCallMessage({
content: assistantContent,
reasoning: {
hasReasoningContent,
reasoningContent
},
toolCalls: normalizedToolCalls,
provider: options?.provider
})
messages.push(toAdd)
addedMessages.push(toAdd)
for (const toolCall of toolCalls) {
if (invalidToolCallIds.has(toolCall.id)) {
callbacks.setToolStatus(toolCall.id, {
isLoading: false,
isQueued: false,
isStreamingArguments: false,
error: 'Tool call arguments were invalid or truncated'
})
const messageToAdd = {
role: 'tool' as const,
tool_call_id: toolCall.id,
content:
'The tool call arguments were invalid or truncated JSON, so the tool was NOT executed. Retry the call; if the arguments were long, split the work into several smaller calls.'
}
messages.push(messageToAdd)
addedMessages.push(messageToAdd)
continue
}
const messageToAdd = await processToolCall({
tools,
toolCall,
helpers,
toolCallbacks: callbacks,
workspace: options?.workspace
})
messages.push(messageToAdd)
addedMessages.push(messageToAdd)
}
appendPendingToolImages(messages, addedMessages, callbacks)
} else if (malformedFunctionCallError) {
// Malformed function call with no tool calls - create artificial tool call to inform AI
const fakeToolCallId = generateRandomString()
// Show error status to user
callbacks.setToolStatus(fakeToolCallId, {
isLoading: false,
content: 'Malformed function call',
error: 'Invalid input given to function call',
toolName: 'unknown'
})
// Add assistant message with fake tool call
const assistantMessage = {
role: 'assistant' as const,
tool_calls: [
{
id: fakeToolCallId,
type: 'function' as const,
function: {
name: 'unknown',
arguments: '{}'
}
}
]
}
messages.push(assistantMessage)
addedMessages.push(assistantMessage)
// Add tool response telling AI to retry
const toolResponse = {
role: 'tool' as const,
tool_call_id: fakeToolCallId,
content: 'Invalid input given to function call, MUST TRY WITH SIMPLER ARGUMENTS'
}
messages.push(toolResponse)
addedMessages.push(toolResponse)
} else {
return { shouldContinue: false, tokenUsage }
}
return { shouldContinue: true, tokenUsage }
}
export function getResponseFromEvent(part: OpenAI.Chat.Completions.ChatCompletionChunk): string {
return part.choices?.[0]?.delta?.content || ''
}
export async function copilot(
scriptOptions: CopilotOptions,
generatedCode: Writable<string>,
abortController: AbortController,
generatedExplanation?: Writable<string>
) {
const { prompt, systemPrompt } = await getPrompts(scriptOptions)
const completion = await getCompletion(
[
{
role: 'system',
content: systemPrompt
},
{
role: 'user',
content: prompt
}
],
abortController
)
let response = ''
let code = ''
for await (const part of completion) {
response += getResponseFromEvent(part)
let match = response.match(/```[a-zA-Z]+\n([\s\S]*?)\n```/)
if (match) {
// if we have a full code block
code = match[1]
generatedCode.set(code)
if (scriptOptions.type === 'fix') {
// in fix mode, check for explanation
let explanationMatch = response.match(/<explanation>([\s\S]+)<\/explanation>/)
if (explanationMatch) {
const explanation = explanationMatch[1].trim()
generatedExplanation?.set(explanation)
break
}
explanationMatch = response.match(/<explanation>([\s\S]+)/)
if (!explanationMatch) {
continue
}
const explanation = explanationMatch[1].replace(/<\/?e?x?p?l?a?n?a?t?i?o?n?>?$/, '').trim()
generatedExplanation?.set(explanation)
continue
} else {
// otherwise stop generating
break
}
}
// partial code block, keep going
match = response.match(/```[a-zA-Z]+\n([\s\S]*)/)
if (!match) {
continue
}
code = match[1]
if (!code.endsWith('`')) {
// skip displaying if possible that part of three ticks (end of code block)s
generatedCode.set(code)
}
}
// make sure we display the latest and complete code
generatedCode.set(code)
if (code.length === 0) {
throw new Error('No code block found')
}
return code
}