refactor: address all 7 PR review comments
1. Fix header comment — document correct env var precedence (apiKey → GITHUB_COPILOT_TOKEN → GITHUB_TOKEN → device flow) 2. Use application/x-www-form-urlencoded for device code endpoint 3. Use application/x-www-form-urlencoded for poll endpoint 4. Add mutex (promise-based) on #getSessionToken to prevent concurrent token refreshes and duplicate device flow prompts 5. Add DeviceCodeCallback + CopilotAdapterOptions so callers can control device flow output instead of hardcoded console.log 6. Extract shared OpenAI wire-format helpers into openai-common.ts, imported by both openai.ts and copilot.ts (-142 lines net) 7. Update createAdapter JSDoc to mention copilot env vars
This commit is contained in:
parent
eedfeb17a2
commit
8371cdb7c0
|
|
@ -42,8 +42,12 @@ export type SupportedProvider = 'anthropic' | 'copilot' | 'openai'
|
||||||
/**
|
/**
|
||||||
* Instantiate the appropriate {@link LLMAdapter} for the given provider.
|
* Instantiate the appropriate {@link LLMAdapter} for the given provider.
|
||||||
*
|
*
|
||||||
* API keys fall back to the standard environment variables
|
* API keys fall back to the standard environment variables when not supplied
|
||||||
* (`ANTHROPIC_API_KEY` / `OPENAI_API_KEY`) when not supplied explicitly.
|
* explicitly:
|
||||||
|
* - `anthropic` → `ANTHROPIC_API_KEY`
|
||||||
|
* - `openai` → `OPENAI_API_KEY`
|
||||||
|
* - `copilot` → `GITHUB_COPILOT_TOKEN` / `GITHUB_TOKEN`, or interactive
|
||||||
|
* OAuth2 device flow if neither is set
|
||||||
*
|
*
|
||||||
* Adapters are imported lazily so that projects using only one provider
|
* Adapters are imported lazily so that projects using only one provider
|
||||||
* are not forced to install the SDK for the other.
|
* are not forced to install the SDK for the other.
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,20 @@
|
||||||
*
|
*
|
||||||
* Uses the OpenAI-compatible Copilot Chat Completions endpoint at
|
* Uses the OpenAI-compatible Copilot Chat Completions endpoint at
|
||||||
* `https://api.githubcopilot.com`. Authentication requires a GitHub token
|
* `https://api.githubcopilot.com`. Authentication requires a GitHub token
|
||||||
* (e.g. from `gh auth token`) which is exchanged for a short-lived Copilot
|
* which is exchanged for a short-lived Copilot session token via the
|
||||||
* session token via the internal token endpoint.
|
* internal token endpoint.
|
||||||
*
|
*
|
||||||
* API key resolution order:
|
* API key resolution order:
|
||||||
* 1. `apiKey` constructor argument
|
* 1. `apiKey` constructor argument
|
||||||
* 2. `GITHUB_TOKEN` environment variable
|
* 2. `GITHUB_COPILOT_TOKEN` environment variable
|
||||||
|
* 3. `GITHUB_TOKEN` environment variable
|
||||||
|
* 4. Interactive OAuth2 device flow (prompts the user to sign in)
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```ts
|
* ```ts
|
||||||
* import { CopilotAdapter } from './copilot.js'
|
* import { CopilotAdapter } from './copilot.js'
|
||||||
*
|
*
|
||||||
* const adapter = new CopilotAdapter() // uses GITHUB_TOKEN env var
|
* const adapter = new CopilotAdapter() // uses GITHUB_COPILOT_TOKEN, falling back to GITHUB_TOKEN
|
||||||
* const response = await adapter.chat(messages, {
|
* const response = await adapter.chat(messages, {
|
||||||
* model: 'claude-sonnet-4',
|
* model: 'claude-sonnet-4',
|
||||||
* maxTokens: 4096,
|
* maxTokens: 4096,
|
||||||
|
|
@ -24,14 +26,7 @@
|
||||||
|
|
||||||
import OpenAI from 'openai'
|
import OpenAI from 'openai'
|
||||||
import type {
|
import type {
|
||||||
ChatCompletion,
|
|
||||||
ChatCompletionAssistantMessageParam,
|
|
||||||
ChatCompletionChunk,
|
ChatCompletionChunk,
|
||||||
ChatCompletionMessageParam,
|
|
||||||
ChatCompletionMessageToolCall,
|
|
||||||
ChatCompletionTool,
|
|
||||||
ChatCompletionToolMessageParam,
|
|
||||||
ChatCompletionUserMessageParam,
|
|
||||||
} from 'openai/resources/chat/completions/index.js'
|
} from 'openai/resources/chat/completions/index.js'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -47,6 +42,13 @@ import type {
|
||||||
ToolUseBlock,
|
ToolUseBlock,
|
||||||
} from '../types.js'
|
} from '../types.js'
|
||||||
|
|
||||||
|
import {
|
||||||
|
toOpenAITool,
|
||||||
|
fromOpenAICompletion,
|
||||||
|
normalizeFinishReason,
|
||||||
|
buildOpenAIMessageList,
|
||||||
|
} from './openai-common.js'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Copilot auth — OAuth2 device flow + token exchange
|
// Copilot auth — OAuth2 device flow + token exchange
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -81,22 +83,38 @@ interface PollResponse {
|
||||||
error_description?: string
|
error_description?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback invoked when the OAuth2 device flow needs the user to authorize.
|
||||||
|
* Receives the verification URI and user code. If not provided, defaults to
|
||||||
|
* printing them to stdout.
|
||||||
|
*/
|
||||||
|
export type DeviceCodeCallback = (verificationUri: string, userCode: string) => void
|
||||||
|
|
||||||
|
const defaultDeviceCodeCallback: DeviceCodeCallback = (uri, code) => {
|
||||||
|
console.log(`\n┌─────────────────────────────────────────────┐`)
|
||||||
|
console.log(`│ GitHub Copilot — Sign in │`)
|
||||||
|
console.log(`│ │`)
|
||||||
|
console.log(`│ Open: ${uri.padEnd(35)}│`)
|
||||||
|
console.log(`│ Code: ${code.padEnd(35)}│`)
|
||||||
|
console.log(`└─────────────────────────────────────────────┘\n`)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start the GitHub OAuth2 device code flow with the Copilot client ID.
|
* Start the GitHub OAuth2 device code flow with the Copilot client ID.
|
||||||
*
|
*
|
||||||
* Prints a user code and URL to stdout, then polls until the user completes
|
* Calls `onDeviceCode` with the verification URI and user code, then polls
|
||||||
* authorization in their browser. Returns a GitHub OAuth token scoped for
|
* until the user completes authorization. Returns a GitHub OAuth token
|
||||||
* Copilot access.
|
* scoped for Copilot access.
|
||||||
*/
|
*/
|
||||||
async function deviceCodeLogin(): Promise<string> {
|
async function deviceCodeLogin(onDeviceCode: DeviceCodeCallback): Promise<string> {
|
||||||
// Step 1: Request a device code
|
// Step 1: Request a device code
|
||||||
const codeRes = await fetch(DEVICE_CODE_URL, {
|
const codeRes = await fetch(DEVICE_CODE_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ client_id: COPILOT_CLIENT_ID, scope: 'copilot' }),
|
body: new URLSearchParams({ client_id: COPILOT_CLIENT_ID, scope: 'copilot' }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!codeRes.ok) {
|
if (!codeRes.ok) {
|
||||||
|
|
@ -106,13 +124,8 @@ async function deviceCodeLogin(): Promise<string> {
|
||||||
|
|
||||||
const codeData = (await codeRes.json()) as DeviceCodeResponse
|
const codeData = (await codeRes.json()) as DeviceCodeResponse
|
||||||
|
|
||||||
// Step 2: Prompt the user
|
// Step 2: Prompt the user via callback
|
||||||
console.log(`\n┌─────────────────────────────────────────────┐`)
|
onDeviceCode(codeData.verification_uri, codeData.user_code)
|
||||||
console.log(`│ GitHub Copilot — Sign in │`)
|
|
||||||
console.log(`│ │`)
|
|
||||||
console.log(`│ Open: ${codeData.verification_uri.padEnd(35)}│`)
|
|
||||||
console.log(`│ Code: ${codeData.user_code.padEnd(35)}│`)
|
|
||||||
console.log(`└─────────────────────────────────────────────┘\n`)
|
|
||||||
|
|
||||||
// Step 3: Poll for the user to complete auth
|
// Step 3: Poll for the user to complete auth
|
||||||
const interval = (codeData.interval || 5) * 1000
|
const interval = (codeData.interval || 5) * 1000
|
||||||
|
|
@ -125,9 +138,9 @@ async function deviceCodeLogin(): Promise<string> {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: new URLSearchParams({
|
||||||
client_id: COPILOT_CLIENT_ID,
|
client_id: COPILOT_CLIENT_ID,
|
||||||
device_code: codeData.device_code,
|
device_code: codeData.device_code,
|
||||||
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
||||||
|
|
@ -182,189 +195,35 @@ async function fetchCopilotToken(githubToken: string): Promise<CopilotTokenRespo
|
||||||
return (await res.json()) as CopilotTokenResponse
|
return (await res.json()) as CopilotTokenResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Internal helpers — framework → OpenAI (shared with openai.ts pattern)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function toOpenAITool(tool: LLMToolDef): ChatCompletionTool {
|
|
||||||
return {
|
|
||||||
type: 'function',
|
|
||||||
function: {
|
|
||||||
name: tool.name,
|
|
||||||
description: tool.description,
|
|
||||||
parameters: tool.inputSchema as Record<string, unknown>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasToolResults(msg: LLMMessage): boolean {
|
|
||||||
return msg.content.some((b) => b.type === 'tool_result')
|
|
||||||
}
|
|
||||||
|
|
||||||
function toOpenAIMessages(messages: LLMMessage[]): ChatCompletionMessageParam[] {
|
|
||||||
const result: ChatCompletionMessageParam[] = []
|
|
||||||
|
|
||||||
for (const msg of messages) {
|
|
||||||
if (msg.role === 'assistant') {
|
|
||||||
result.push(toOpenAIAssistantMessage(msg))
|
|
||||||
} else {
|
|
||||||
if (!hasToolResults(msg)) {
|
|
||||||
result.push(toOpenAIUserMessage(msg))
|
|
||||||
} else {
|
|
||||||
const nonToolBlocks = msg.content.filter((b) => b.type !== 'tool_result')
|
|
||||||
if (nonToolBlocks.length > 0) {
|
|
||||||
result.push(toOpenAIUserMessage({ role: 'user', content: nonToolBlocks }))
|
|
||||||
}
|
|
||||||
for (const block of msg.content) {
|
|
||||||
if (block.type === 'tool_result') {
|
|
||||||
const toolMsg: ChatCompletionToolMessageParam = {
|
|
||||||
role: 'tool',
|
|
||||||
tool_call_id: block.tool_use_id,
|
|
||||||
content: block.content,
|
|
||||||
}
|
|
||||||
result.push(toolMsg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function toOpenAIUserMessage(msg: LLMMessage): ChatCompletionUserMessageParam {
|
|
||||||
if (msg.content.length === 1 && msg.content[0]?.type === 'text') {
|
|
||||||
return { role: 'user', content: msg.content[0].text }
|
|
||||||
}
|
|
||||||
|
|
||||||
type ContentPart = OpenAI.Chat.ChatCompletionContentPartText | OpenAI.Chat.ChatCompletionContentPartImage
|
|
||||||
const parts: ContentPart[] = []
|
|
||||||
|
|
||||||
for (const block of msg.content) {
|
|
||||||
if (block.type === 'text') {
|
|
||||||
parts.push({ type: 'text', text: block.text })
|
|
||||||
} else if (block.type === 'image') {
|
|
||||||
parts.push({
|
|
||||||
type: 'image_url',
|
|
||||||
image_url: {
|
|
||||||
url: `data:${block.source.media_type};base64,${block.source.data}`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { role: 'user', content: parts }
|
|
||||||
}
|
|
||||||
|
|
||||||
function toOpenAIAssistantMessage(msg: LLMMessage): ChatCompletionAssistantMessageParam {
|
|
||||||
const toolCalls: ChatCompletionMessageToolCall[] = []
|
|
||||||
const textParts: string[] = []
|
|
||||||
|
|
||||||
for (const block of msg.content) {
|
|
||||||
if (block.type === 'tool_use') {
|
|
||||||
toolCalls.push({
|
|
||||||
id: block.id,
|
|
||||||
type: 'function',
|
|
||||||
function: {
|
|
||||||
name: block.name,
|
|
||||||
arguments: JSON.stringify(block.input),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (block.type === 'text') {
|
|
||||||
textParts.push(block.text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const assistantMsg: ChatCompletionAssistantMessageParam = {
|
|
||||||
role: 'assistant',
|
|
||||||
content: textParts.length > 0 ? textParts.join('') : null,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toolCalls.length > 0) {
|
|
||||||
assistantMsg.tool_calls = toolCalls
|
|
||||||
}
|
|
||||||
|
|
||||||
return assistantMsg
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Internal helpers — OpenAI → framework
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function fromOpenAICompletion(completion: ChatCompletion): LLMResponse {
|
|
||||||
const choice = completion.choices[0]
|
|
||||||
if (choice === undefined) {
|
|
||||||
throw new Error('Copilot returned a completion with no choices')
|
|
||||||
}
|
|
||||||
|
|
||||||
const content: ContentBlock[] = []
|
|
||||||
const message = choice.message
|
|
||||||
|
|
||||||
if (message.content !== null && message.content !== undefined) {
|
|
||||||
const textBlock: TextBlock = { type: 'text', text: message.content }
|
|
||||||
content.push(textBlock)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const toolCall of message.tool_calls ?? []) {
|
|
||||||
let parsedInput: Record<string, unknown> = {}
|
|
||||||
try {
|
|
||||||
const parsed: unknown = JSON.parse(toolCall.function.arguments)
|
|
||||||
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
||||||
parsedInput = parsed as Record<string, unknown>
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Malformed arguments — surface as empty object.
|
|
||||||
}
|
|
||||||
|
|
||||||
const toolUseBlock: ToolUseBlock = {
|
|
||||||
type: 'tool_use',
|
|
||||||
id: toolCall.id,
|
|
||||||
name: toolCall.function.name,
|
|
||||||
input: parsedInput,
|
|
||||||
}
|
|
||||||
content.push(toolUseBlock)
|
|
||||||
}
|
|
||||||
|
|
||||||
const stopReason = normalizeFinishReason(choice.finish_reason ?? 'stop')
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: completion.id,
|
|
||||||
content,
|
|
||||||
model: completion.model,
|
|
||||||
stop_reason: stopReason,
|
|
||||||
usage: {
|
|
||||||
input_tokens: completion.usage?.prompt_tokens ?? 0,
|
|
||||||
output_tokens: completion.usage?.completion_tokens ?? 0,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeFinishReason(reason: string): string {
|
|
||||||
switch (reason) {
|
|
||||||
case 'stop': return 'end_turn'
|
|
||||||
case 'tool_calls': return 'tool_use'
|
|
||||||
case 'length': return 'max_tokens'
|
|
||||||
case 'content_filter': return 'content_filter'
|
|
||||||
default: return reason
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Adapter implementation
|
// Adapter implementation
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Options for the {@link CopilotAdapter} constructor. */
|
||||||
|
export interface CopilotAdapterOptions {
|
||||||
|
/** GitHub OAuth token already scoped for Copilot. Falls back to env vars. */
|
||||||
|
apiKey?: string
|
||||||
|
/**
|
||||||
|
* Callback invoked when the OAuth2 device flow needs user action.
|
||||||
|
* Defaults to printing the verification URI and user code to stdout.
|
||||||
|
*/
|
||||||
|
onDeviceCode?: DeviceCodeCallback
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LLM adapter backed by the GitHub Copilot Chat Completions API.
|
* LLM adapter backed by the GitHub Copilot Chat Completions API.
|
||||||
*
|
*
|
||||||
* Authentication options (tried in order):
|
* Authentication options (tried in order):
|
||||||
* 1. `apiKey` constructor arg — a GitHub OAuth token already scoped for Copilot
|
* 1. `apiKey` constructor arg — a GitHub OAuth token already scoped for Copilot
|
||||||
* 2. `GITHUB_COPILOT_TOKEN` env var — same as above
|
* 2. `GITHUB_COPILOT_TOKEN` env var
|
||||||
* 3. Interactive OAuth2 device flow — prompts the user to sign in via browser
|
* 3. `GITHUB_TOKEN` env var
|
||||||
|
* 4. Interactive OAuth2 device flow
|
||||||
*
|
*
|
||||||
* The GitHub token is exchanged for a short-lived Copilot session token, which
|
* The GitHub token is exchanged for a short-lived Copilot session token, which
|
||||||
* is cached and auto-refreshed.
|
* is cached and auto-refreshed.
|
||||||
*
|
*
|
||||||
* Thread-safe — a single instance may be shared across concurrent agent runs.
|
* Thread-safe — a single instance may be shared across concurrent agent runs.
|
||||||
|
* Concurrent token refreshes are serialised via an internal mutex.
|
||||||
*/
|
*/
|
||||||
export class CopilotAdapter implements LLMAdapter {
|
export class CopilotAdapter implements LLMAdapter {
|
||||||
readonly name = 'copilot'
|
readonly name = 'copilot'
|
||||||
|
|
@ -372,17 +231,25 @@ export class CopilotAdapter implements LLMAdapter {
|
||||||
#githubToken: string | null
|
#githubToken: string | null
|
||||||
#cachedToken: string | null = null
|
#cachedToken: string | null = null
|
||||||
#tokenExpiresAt = 0
|
#tokenExpiresAt = 0
|
||||||
|
#refreshPromise: Promise<string> | null = null
|
||||||
|
readonly #onDeviceCode: DeviceCodeCallback
|
||||||
|
|
||||||
constructor(apiKey?: string) {
|
constructor(apiKeyOrOptions?: string | CopilotAdapterOptions) {
|
||||||
this.#githubToken = apiKey
|
const opts = typeof apiKeyOrOptions === 'string'
|
||||||
|
? { apiKey: apiKeyOrOptions }
|
||||||
|
: apiKeyOrOptions ?? {}
|
||||||
|
|
||||||
|
this.#githubToken = opts.apiKey
|
||||||
?? process.env['GITHUB_COPILOT_TOKEN']
|
?? process.env['GITHUB_COPILOT_TOKEN']
|
||||||
?? process.env['GITHUB_TOKEN']
|
?? process.env['GITHUB_TOKEN']
|
||||||
?? null
|
?? null
|
||||||
|
this.#onDeviceCode = opts.onDeviceCode ?? defaultDeviceCodeCallback
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return a valid Copilot session token, refreshing if necessary.
|
* Return a valid Copilot session token, refreshing if necessary.
|
||||||
* If no GitHub token is available, triggers the interactive device flow.
|
* If no GitHub token is available, triggers the interactive device flow.
|
||||||
|
* Concurrent calls share a single in-flight refresh to avoid races.
|
||||||
*/
|
*/
|
||||||
async #getSessionToken(): Promise<string> {
|
async #getSessionToken(): Promise<string> {
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
|
@ -390,9 +257,22 @@ export class CopilotAdapter implements LLMAdapter {
|
||||||
return this.#cachedToken
|
return this.#cachedToken
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we don't have a GitHub token yet, do the device flow
|
// If another call is already refreshing, piggyback on that promise
|
||||||
|
if (this.#refreshPromise) {
|
||||||
|
return this.#refreshPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#refreshPromise = this.#doRefresh()
|
||||||
|
try {
|
||||||
|
return await this.#refreshPromise
|
||||||
|
} finally {
|
||||||
|
this.#refreshPromise = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #doRefresh(): Promise<string> {
|
||||||
if (!this.#githubToken) {
|
if (!this.#githubToken) {
|
||||||
this.#githubToken = await deviceCodeLogin()
|
this.#githubToken = await deviceCodeLogin(this.#onDeviceCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
const resp = await fetchCopilotToken(this.#githubToken)
|
const resp = await fetchCopilotToken(this.#githubToken)
|
||||||
|
|
@ -568,36 +448,6 @@ export class CopilotAdapter implements LLMAdapter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Private utility
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function buildOpenAIMessageList(
|
|
||||||
messages: LLMMessage[],
|
|
||||||
systemPrompt: string | undefined,
|
|
||||||
): ChatCompletionMessageParam[] {
|
|
||||||
const result: ChatCompletionMessageParam[] = []
|
|
||||||
|
|
||||||
if (systemPrompt !== undefined && systemPrompt.length > 0) {
|
|
||||||
result.push({ role: 'system', content: systemPrompt })
|
|
||||||
}
|
|
||||||
|
|
||||||
result.push(...toOpenAIMessages(messages))
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-export types that consumers of this module commonly need alongside the adapter.
|
|
||||||
export type {
|
|
||||||
ContentBlock,
|
|
||||||
LLMAdapter,
|
|
||||||
LLMChatOptions,
|
|
||||||
LLMMessage,
|
|
||||||
LLMResponse,
|
|
||||||
LLMStreamOptions,
|
|
||||||
LLMToolDef,
|
|
||||||
StreamEvent,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Premium request multipliers
|
// Premium request multipliers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,255 @@
|
||||||
|
/**
|
||||||
|
* @fileoverview Shared OpenAI wire-format conversion helpers.
|
||||||
|
*
|
||||||
|
* Both the OpenAI and Copilot adapters use the OpenAI Chat Completions API
|
||||||
|
* format. This module contains the common conversion logic so it isn't
|
||||||
|
* duplicated across adapters.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import OpenAI from 'openai'
|
||||||
|
import type {
|
||||||
|
ChatCompletion,
|
||||||
|
ChatCompletionAssistantMessageParam,
|
||||||
|
ChatCompletionMessageParam,
|
||||||
|
ChatCompletionMessageToolCall,
|
||||||
|
ChatCompletionTool,
|
||||||
|
ChatCompletionToolMessageParam,
|
||||||
|
ChatCompletionUserMessageParam,
|
||||||
|
} from 'openai/resources/chat/completions/index.js'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ContentBlock,
|
||||||
|
LLMMessage,
|
||||||
|
LLMResponse,
|
||||||
|
LLMToolDef,
|
||||||
|
TextBlock,
|
||||||
|
ToolUseBlock,
|
||||||
|
} from '../types.js'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Framework → OpenAI
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a framework {@link LLMToolDef} to an OpenAI {@link ChatCompletionTool}.
|
||||||
|
*/
|
||||||
|
export function toOpenAITool(tool: LLMToolDef): ChatCompletionTool {
|
||||||
|
return {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
parameters: tool.inputSchema as Record<string, unknown>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine whether a framework message contains any `tool_result` content
|
||||||
|
* blocks, which must be serialised as separate OpenAI `tool`-role messages.
|
||||||
|
*/
|
||||||
|
function hasToolResults(msg: LLMMessage): boolean {
|
||||||
|
return msg.content.some((b) => b.type === 'tool_result')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert framework {@link LLMMessage}s into OpenAI
|
||||||
|
* {@link ChatCompletionMessageParam} entries.
|
||||||
|
*
|
||||||
|
* `tool_result` blocks are expanded into top-level `tool`-role messages
|
||||||
|
* because OpenAI uses a dedicated role for tool results rather than embedding
|
||||||
|
* them inside user-content arrays.
|
||||||
|
*/
|
||||||
|
export function toOpenAIMessages(messages: LLMMessage[]): ChatCompletionMessageParam[] {
|
||||||
|
const result: ChatCompletionMessageParam[] = []
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (msg.role === 'assistant') {
|
||||||
|
result.push(toOpenAIAssistantMessage(msg))
|
||||||
|
} else {
|
||||||
|
// user role
|
||||||
|
if (!hasToolResults(msg)) {
|
||||||
|
result.push(toOpenAIUserMessage(msg))
|
||||||
|
} else {
|
||||||
|
const nonToolBlocks = msg.content.filter((b) => b.type !== 'tool_result')
|
||||||
|
if (nonToolBlocks.length > 0) {
|
||||||
|
result.push(toOpenAIUserMessage({ role: 'user', content: nonToolBlocks }))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const block of msg.content) {
|
||||||
|
if (block.type === 'tool_result') {
|
||||||
|
const toolMsg: ChatCompletionToolMessageParam = {
|
||||||
|
role: 'tool',
|
||||||
|
tool_call_id: block.tool_use_id,
|
||||||
|
content: block.content,
|
||||||
|
}
|
||||||
|
result.push(toolMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a `user`-role framework message into an OpenAI user message.
|
||||||
|
* Image blocks are converted to the OpenAI image_url content part format.
|
||||||
|
*/
|
||||||
|
function toOpenAIUserMessage(msg: LLMMessage): ChatCompletionUserMessageParam {
|
||||||
|
if (msg.content.length === 1 && msg.content[0]?.type === 'text') {
|
||||||
|
return { role: 'user', content: msg.content[0].text }
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContentPart = OpenAI.Chat.ChatCompletionContentPartText | OpenAI.Chat.ChatCompletionContentPartImage
|
||||||
|
const parts: ContentPart[] = []
|
||||||
|
|
||||||
|
for (const block of msg.content) {
|
||||||
|
if (block.type === 'text') {
|
||||||
|
parts.push({ type: 'text', text: block.text })
|
||||||
|
} else if (block.type === 'image') {
|
||||||
|
parts.push({
|
||||||
|
type: 'image_url',
|
||||||
|
image_url: {
|
||||||
|
url: `data:${block.source.media_type};base64,${block.source.data}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// tool_result blocks are handled by the caller (toOpenAIMessages); skip here.
|
||||||
|
}
|
||||||
|
|
||||||
|
return { role: 'user', content: parts }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an `assistant`-role framework message into an OpenAI assistant message.
|
||||||
|
* `tool_use` blocks become `tool_calls`; `text` blocks become message content.
|
||||||
|
*/
|
||||||
|
function toOpenAIAssistantMessage(msg: LLMMessage): ChatCompletionAssistantMessageParam {
|
||||||
|
const toolCalls: ChatCompletionMessageToolCall[] = []
|
||||||
|
const textParts: string[] = []
|
||||||
|
|
||||||
|
for (const block of msg.content) {
|
||||||
|
if (block.type === 'tool_use') {
|
||||||
|
toolCalls.push({
|
||||||
|
id: block.id,
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: block.name,
|
||||||
|
arguments: JSON.stringify(block.input),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else if (block.type === 'text') {
|
||||||
|
textParts.push(block.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistantMsg: ChatCompletionAssistantMessageParam = {
|
||||||
|
role: 'assistant',
|
||||||
|
content: textParts.length > 0 ? textParts.join('') : null,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolCalls.length > 0) {
|
||||||
|
assistantMsg.tool_calls = toolCalls
|
||||||
|
}
|
||||||
|
|
||||||
|
return assistantMsg
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// OpenAI → Framework
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an OpenAI {@link ChatCompletion} into a framework {@link LLMResponse}.
|
||||||
|
*
|
||||||
|
* Takes only the first choice (index 0), consistent with how the framework
|
||||||
|
* is designed for single-output agents.
|
||||||
|
*/
|
||||||
|
export function fromOpenAICompletion(completion: ChatCompletion): LLMResponse {
|
||||||
|
const choice = completion.choices[0]
|
||||||
|
if (choice === undefined) {
|
||||||
|
throw new Error('OpenAI returned a completion with no choices')
|
||||||
|
}
|
||||||
|
|
||||||
|
const content: ContentBlock[] = []
|
||||||
|
const message = choice.message
|
||||||
|
|
||||||
|
if (message.content !== null && message.content !== undefined) {
|
||||||
|
const textBlock: TextBlock = { type: 'text', text: message.content }
|
||||||
|
content.push(textBlock)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const toolCall of message.tool_calls ?? []) {
|
||||||
|
let parsedInput: Record<string, unknown> = {}
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(toolCall.function.arguments)
|
||||||
|
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
parsedInput = parsed as Record<string, unknown>
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Malformed arguments from the model — surface as empty object.
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolUseBlock: ToolUseBlock = {
|
||||||
|
type: 'tool_use',
|
||||||
|
id: toolCall.id,
|
||||||
|
name: toolCall.function.name,
|
||||||
|
input: parsedInput,
|
||||||
|
}
|
||||||
|
content.push(toolUseBlock)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopReason = normalizeFinishReason(choice.finish_reason ?? 'stop')
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: completion.id,
|
||||||
|
content,
|
||||||
|
model: completion.model,
|
||||||
|
stop_reason: stopReason,
|
||||||
|
usage: {
|
||||||
|
input_tokens: completion.usage?.prompt_tokens ?? 0,
|
||||||
|
output_tokens: completion.usage?.completion_tokens ?? 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize an OpenAI `finish_reason` string to the framework's canonical
|
||||||
|
* stop-reason vocabulary.
|
||||||
|
*
|
||||||
|
* Mapping:
|
||||||
|
* - `'stop'` → `'end_turn'`
|
||||||
|
* - `'tool_calls'` → `'tool_use'`
|
||||||
|
* - `'length'` → `'max_tokens'`
|
||||||
|
* - `'content_filter'` → `'content_filter'`
|
||||||
|
* - anything else → passed through unchanged
|
||||||
|
*/
|
||||||
|
export function normalizeFinishReason(reason: string): string {
|
||||||
|
switch (reason) {
|
||||||
|
case 'stop': return 'end_turn'
|
||||||
|
case 'tool_calls': return 'tool_use'
|
||||||
|
case 'length': return 'max_tokens'
|
||||||
|
case 'content_filter': return 'content_filter'
|
||||||
|
default: return reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepend a system message when `systemPrompt` is provided, then append the
|
||||||
|
* converted conversation messages.
|
||||||
|
*/
|
||||||
|
export function buildOpenAIMessageList(
|
||||||
|
messages: LLMMessage[],
|
||||||
|
systemPrompt: string | undefined,
|
||||||
|
): ChatCompletionMessageParam[] {
|
||||||
|
const result: ChatCompletionMessageParam[] = []
|
||||||
|
|
||||||
|
if (systemPrompt !== undefined && systemPrompt.length > 0) {
|
||||||
|
result.push({ role: 'system', content: systemPrompt })
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(...toOpenAIMessages(messages))
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
@ -32,14 +32,7 @@
|
||||||
|
|
||||||
import OpenAI from 'openai'
|
import OpenAI from 'openai'
|
||||||
import type {
|
import type {
|
||||||
ChatCompletion,
|
|
||||||
ChatCompletionAssistantMessageParam,
|
|
||||||
ChatCompletionChunk,
|
ChatCompletionChunk,
|
||||||
ChatCompletionMessageParam,
|
|
||||||
ChatCompletionMessageToolCall,
|
|
||||||
ChatCompletionTool,
|
|
||||||
ChatCompletionToolMessageParam,
|
|
||||||
ChatCompletionUserMessageParam,
|
|
||||||
} from 'openai/resources/chat/completions/index.js'
|
} from 'openai/resources/chat/completions/index.js'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -55,231 +48,12 @@ import type {
|
||||||
ToolUseBlock,
|
ToolUseBlock,
|
||||||
} from '../types.js'
|
} from '../types.js'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
import {
|
||||||
// Internal helpers — framework → OpenAI
|
toOpenAITool,
|
||||||
// ---------------------------------------------------------------------------
|
fromOpenAICompletion,
|
||||||
|
normalizeFinishReason,
|
||||||
/**
|
buildOpenAIMessageList,
|
||||||
* Convert a framework {@link LLMToolDef} to an OpenAI {@link ChatCompletionTool}.
|
} from './openai-common.js'
|
||||||
*
|
|
||||||
* OpenAI wraps the function definition inside a `function` key and a `type`
|
|
||||||
* discriminant. The `inputSchema` is already a JSON Schema object.
|
|
||||||
*/
|
|
||||||
function toOpenAITool(tool: LLMToolDef): ChatCompletionTool {
|
|
||||||
return {
|
|
||||||
type: 'function',
|
|
||||||
function: {
|
|
||||||
name: tool.name,
|
|
||||||
description: tool.description,
|
|
||||||
parameters: tool.inputSchema as Record<string, unknown>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine whether a framework message contains any `tool_result` content
|
|
||||||
* blocks, which must be serialised as separate OpenAI `tool`-role messages.
|
|
||||||
*/
|
|
||||||
function hasToolResults(msg: LLMMessage): boolean {
|
|
||||||
return msg.content.some((b) => b.type === 'tool_result')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a single framework {@link LLMMessage} into one or more OpenAI
|
|
||||||
* {@link ChatCompletionMessageParam} entries.
|
|
||||||
*
|
|
||||||
* The expansion is necessary because OpenAI represents tool results as
|
|
||||||
* top-level messages with role `tool`, whereas in our model they are content
|
|
||||||
* blocks inside a `user` message.
|
|
||||||
*
|
|
||||||
* Expansion rules:
|
|
||||||
* - A `user` message containing only text/image blocks → single user message
|
|
||||||
* - A `user` message containing `tool_result` blocks → one `tool` message per
|
|
||||||
* tool_result block; any remaining text/image blocks are folded into an
|
|
||||||
* additional user message prepended to the group
|
|
||||||
* - An `assistant` message → single assistant message with optional tool_calls
|
|
||||||
*/
|
|
||||||
function toOpenAIMessages(messages: LLMMessage[]): ChatCompletionMessageParam[] {
|
|
||||||
const result: ChatCompletionMessageParam[] = []
|
|
||||||
|
|
||||||
for (const msg of messages) {
|
|
||||||
if (msg.role === 'assistant') {
|
|
||||||
result.push(toOpenAIAssistantMessage(msg))
|
|
||||||
} else {
|
|
||||||
// user role
|
|
||||||
if (!hasToolResults(msg)) {
|
|
||||||
result.push(toOpenAIUserMessage(msg))
|
|
||||||
} else {
|
|
||||||
// Split: text/image blocks become a user message (if any exist), then
|
|
||||||
// each tool_result block becomes an independent tool message.
|
|
||||||
const nonToolBlocks = msg.content.filter((b) => b.type !== 'tool_result')
|
|
||||||
if (nonToolBlocks.length > 0) {
|
|
||||||
result.push(toOpenAIUserMessage({ role: 'user', content: nonToolBlocks }))
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const block of msg.content) {
|
|
||||||
if (block.type === 'tool_result') {
|
|
||||||
const toolMsg: ChatCompletionToolMessageParam = {
|
|
||||||
role: 'tool',
|
|
||||||
tool_call_id: block.tool_use_id,
|
|
||||||
content: block.content,
|
|
||||||
}
|
|
||||||
result.push(toolMsg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a `user`-role framework message into an OpenAI user message.
|
|
||||||
* Image blocks are converted to the OpenAI image_url content part format.
|
|
||||||
*/
|
|
||||||
function toOpenAIUserMessage(msg: LLMMessage): ChatCompletionUserMessageParam {
|
|
||||||
// If the entire content is a single text block, use the compact string form
|
|
||||||
// to keep the request payload smaller.
|
|
||||||
if (msg.content.length === 1 && msg.content[0]?.type === 'text') {
|
|
||||||
return { role: 'user', content: msg.content[0].text }
|
|
||||||
}
|
|
||||||
|
|
||||||
type ContentPart = OpenAI.Chat.ChatCompletionContentPartText | OpenAI.Chat.ChatCompletionContentPartImage
|
|
||||||
const parts: ContentPart[] = []
|
|
||||||
|
|
||||||
for (const block of msg.content) {
|
|
||||||
if (block.type === 'text') {
|
|
||||||
parts.push({ type: 'text', text: block.text })
|
|
||||||
} else if (block.type === 'image') {
|
|
||||||
parts.push({
|
|
||||||
type: 'image_url',
|
|
||||||
image_url: {
|
|
||||||
url: `data:${block.source.media_type};base64,${block.source.data}`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// tool_result blocks are handled by the caller (toOpenAIMessages); skip here.
|
|
||||||
}
|
|
||||||
|
|
||||||
return { role: 'user', content: parts }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert an `assistant`-role framework message into an OpenAI assistant message.
|
|
||||||
*
|
|
||||||
* Any `tool_use` blocks become `tool_calls`; `text` blocks become the message content.
|
|
||||||
*/
|
|
||||||
function toOpenAIAssistantMessage(msg: LLMMessage): ChatCompletionAssistantMessageParam {
|
|
||||||
const toolCalls: ChatCompletionMessageToolCall[] = []
|
|
||||||
const textParts: string[] = []
|
|
||||||
|
|
||||||
for (const block of msg.content) {
|
|
||||||
if (block.type === 'tool_use') {
|
|
||||||
toolCalls.push({
|
|
||||||
id: block.id,
|
|
||||||
type: 'function',
|
|
||||||
function: {
|
|
||||||
name: block.name,
|
|
||||||
arguments: JSON.stringify(block.input),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (block.type === 'text') {
|
|
||||||
textParts.push(block.text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const assistantMsg: ChatCompletionAssistantMessageParam = {
|
|
||||||
role: 'assistant',
|
|
||||||
content: textParts.length > 0 ? textParts.join('') : null,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toolCalls.length > 0) {
|
|
||||||
assistantMsg.tool_calls = toolCalls
|
|
||||||
}
|
|
||||||
|
|
||||||
return assistantMsg
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Internal helpers — OpenAI → framework
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert an OpenAI {@link ChatCompletion} into a framework {@link LLMResponse}.
|
|
||||||
*
|
|
||||||
* We take only the first choice (index 0), consistent with how the framework
|
|
||||||
* is designed for single-output agents.
|
|
||||||
*/
|
|
||||||
function fromOpenAICompletion(completion: ChatCompletion): LLMResponse {
|
|
||||||
const choice = completion.choices[0]
|
|
||||||
if (choice === undefined) {
|
|
||||||
throw new Error('OpenAI returned a completion with no choices')
|
|
||||||
}
|
|
||||||
|
|
||||||
const content: ContentBlock[] = []
|
|
||||||
const message = choice.message
|
|
||||||
|
|
||||||
if (message.content !== null && message.content !== undefined) {
|
|
||||||
const textBlock: TextBlock = { type: 'text', text: message.content }
|
|
||||||
content.push(textBlock)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const toolCall of message.tool_calls ?? []) {
|
|
||||||
let parsedInput: Record<string, unknown> = {}
|
|
||||||
try {
|
|
||||||
const parsed: unknown = JSON.parse(toolCall.function.arguments)
|
|
||||||
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
||||||
parsedInput = parsed as Record<string, unknown>
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Malformed arguments from the model — surface as empty object.
|
|
||||||
}
|
|
||||||
|
|
||||||
const toolUseBlock: ToolUseBlock = {
|
|
||||||
type: 'tool_use',
|
|
||||||
id: toolCall.id,
|
|
||||||
name: toolCall.function.name,
|
|
||||||
input: parsedInput,
|
|
||||||
}
|
|
||||||
content.push(toolUseBlock)
|
|
||||||
}
|
|
||||||
|
|
||||||
const stopReason = normalizeFinishReason(choice.finish_reason ?? 'stop')
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: completion.id,
|
|
||||||
content,
|
|
||||||
model: completion.model,
|
|
||||||
stop_reason: stopReason,
|
|
||||||
usage: {
|
|
||||||
input_tokens: completion.usage?.prompt_tokens ?? 0,
|
|
||||||
output_tokens: completion.usage?.completion_tokens ?? 0,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize an OpenAI `finish_reason` string to the framework's canonical
|
|
||||||
* stop-reason vocabulary so consumers never need to branch on provider-specific
|
|
||||||
* strings.
|
|
||||||
*
|
|
||||||
* Mapping:
|
|
||||||
* - `'stop'` → `'end_turn'`
|
|
||||||
* - `'tool_calls'` → `'tool_use'`
|
|
||||||
* - `'length'` → `'max_tokens'`
|
|
||||||
* - `'content_filter'` → `'content_filter'`
|
|
||||||
* - anything else → passed through unchanged
|
|
||||||
*/
|
|
||||||
function normalizeFinishReason(reason: string): string {
|
|
||||||
switch (reason) {
|
|
||||||
case 'stop': return 'end_turn'
|
|
||||||
case 'tool_calls': return 'tool_use'
|
|
||||||
case 'length': return 'max_tokens'
|
|
||||||
case 'content_filter': return 'content_filter'
|
|
||||||
default: return reason
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Adapter implementation
|
// Adapter implementation
|
||||||
|
|
@ -484,31 +258,6 @@ export class OpenAIAdapter implements LLMAdapter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Private utility
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prepend a system message when `systemPrompt` is provided, then append the
|
|
||||||
* converted conversation messages.
|
|
||||||
*
|
|
||||||
* OpenAI represents system instructions as a message with `role: 'system'`
|
|
||||||
* at the top of the array, not as a separate API parameter.
|
|
||||||
*/
|
|
||||||
function buildOpenAIMessageList(
|
|
||||||
messages: LLMMessage[],
|
|
||||||
systemPrompt: string | undefined,
|
|
||||||
): ChatCompletionMessageParam[] {
|
|
||||||
const result: ChatCompletionMessageParam[] = []
|
|
||||||
|
|
||||||
if (systemPrompt !== undefined && systemPrompt.length > 0) {
|
|
||||||
result.push({ role: 'system', content: systemPrompt })
|
|
||||||
}
|
|
||||||
|
|
||||||
result.push(...toOpenAIMessages(messages))
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-export types that consumers of this module commonly need alongside the adapter.
|
// Re-export types that consumers of this module commonly need alongside the adapter.
|
||||||
export type {
|
export type {
|
||||||
ContentBlock,
|
ContentBlock,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue