Skip to content
Open
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 src/lib/api-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ export const standardHeaders = () => ({
accept: "application/json",
})

const COPILOT_VERSION = "0.26.7"
const COPILOT_VERSION = "0.48.1"
const EDITOR_PLUGIN_VERSION = `copilot-chat/${COPILOT_VERSION}`
const USER_AGENT = `GitHubCopilotChat/${COPILOT_VERSION}`

const API_VERSION = "2025-04-01"
const API_VERSION = "2026-01-09"

export const copilotBaseUrl = (state: State) =>
state.accountType === "individual" ?
Expand Down
3 changes: 3 additions & 0 deletions src/lib/state.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ModelSession } from "~/services/copilot/create-model-session"
import type { ModelsResponse } from "~/services/copilot/get-models"

export interface State {
Expand All @@ -6,6 +7,8 @@ export interface State {

accountType: string
models?: ModelsResponse
modelSession?: ModelSession
proxyBaseUrl?: string
vsCodeVersion?: string

manualApprove: boolean
Expand Down
2 changes: 1 addition & 1 deletion src/lib/tokenizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ const getEncodeChatFunction = async (encoding: string): Promise<Encoder> => {
* Get tokenizer type from model information
*/
export const getTokenizerFromModel = (model: Model): string => {
return model.capabilities.tokenizer || "o200k_base"
return model.capabilities?.tokenizer ?? "o200k_base"
}

/**
Expand Down
10 changes: 8 additions & 2 deletions src/routes/chat-completions/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,16 @@ export async function handleCompletion(c: Context) {

if (state.manualApprove) await awaitApproval()

if (isNullish(payload.max_tokens)) {
// An explicitly-null max_completion_tokens still counts as "present":
// forwarding it together with an injected max_tokens would recreate the
// both-parameters rejection this guard exists to prevent.
if (
isNullish(payload.max_tokens)
&& payload.max_completion_tokens === undefined
) {
payload = {
...payload,
max_tokens: selectedModel?.capabilities.limits.max_output_tokens,
max_tokens: selectedModel?.capabilities?.limits.max_output_tokens,
}
consola.debug("Set max_tokens to:", JSON.stringify(payload.max_tokens))
}
Expand Down
101 changes: 101 additions & 0 deletions src/routes/completions/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { Hono } from "hono"
import { streamSSE } from "hono/streaming"

import { awaitApproval } from "~/lib/approval"
import { forwardError } from "~/lib/error"
import { checkRateLimit } from "~/lib/rate-limit"
import { state } from "~/lib/state"
import {
chatResultToCompletionResult,
completionPayloadToChatPayload,
streamChatAsCompletionChunks,
} from "~/services/copilot/completions-adapter"
import { createChatCompletions } from "~/services/copilot/create-chat-completions"
import {
aggregateProxyCompletionsStream,
createProxyCompletions,
type CompletionsPayload,
} from "~/services/copilot/create-proxy-completions"

export const completionsRoutes = new Hono()

completionsRoutes.post("/", async (c) => {
Comment thread
Copilot marked this conversation as resolved.
Resolved
try {
// same request guards as the chat route: rate limit + manual approval
await checkRateLimit(state)
if (state.manualApprove) await awaitApproval()

const body = (await c.req.json()) as unknown
if (typeof body !== "object" || body === null) {
return c.json({ error: "model is required" }, 400)
}
const payload = body as CompletionsPayload
if (typeof payload.model !== "string" || payload.model.length === 0) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return c.json({ error: "model is required" }, 400)
}

// legacy fill-in-the-middle engines (gpt-41-copilot, ...) live behind
// the traditional proxy completions API; everything else is translated
// through Chat Completions so all models are reachable here
if (payload.model.endsWith("-copilot")) {
if (payload.stream) {
const response = await createProxyCompletions(payload)
const body = response.body
if (!body) {
return c.json({ error: "Empty response body" }, 502)
}
return c.body(body, 200, {
"content-type": "text/event-stream",
"cache-control": "no-cache",
})
}

const result = await aggregateProxyCompletionsStream(payload)
return c.json(result)
}

// array prompts are independent prompts (or FIM prefix/suffix pairs) in
// the legacy contract and cannot be translated to a single chat turn
if (Array.isArray(payload.prompt)) {
return c.json(
{
error:
"Array prompts are only supported for -copilot engine models; chat-model translation accepts a single string prompt",
},
400,
)
}

// FIM suffix has no chat-completions equivalent; reject explicitly
// instead of silently dropping it
if (payload.suffix !== undefined && payload.suffix !== null) {
return c.json(
{ error: "suffix is only supported for -copilot engine models" },
400,
)
}

const chatPayload = completionPayloadToChatPayload(payload)

if (payload.stream) {
const chatStream = (await createChatCompletions({
...chatPayload,
stream: true,
})) as AsyncIterable<{ data?: string | null }>

return streamSSE(c, async (stream) => {
for await (const event of streamChatAsCompletionChunks(chatStream)) {
await stream.writeSSE({ data: event.data })
}
})
}

const chatResult = await createChatCompletions(chatPayload)
const result = chatResultToCompletionResult(
chatResult as Parameters<typeof chatResultToCompletionResult>[0],
)
return c.json(result)
} catch (error) {
return await forwardError(c, error)
}
})
8 changes: 8 additions & 0 deletions src/routes/embeddings/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { Hono } from "hono"

import { awaitApproval } from "~/lib/approval"
import { forwardError } from "~/lib/error"
import { checkRateLimit } from "~/lib/rate-limit"
import { state } from "~/lib/state"
import {
createEmbeddings,
type EmbeddingRequest,
Expand All @@ -10,6 +13,11 @@ export const embeddingRoutes = new Hono()

embeddingRoutes.post("/", async (c) => {
try {
// same request guards as the other generation routes: embeddings
// calls must not bypass --rate-limit / --manual safeguards
await checkRateLimit(state)
if (state.manualApprove) await awaitApproval()

const paylod = await c.req.json<EmbeddingRequest>()
const response = await createEmbeddings(paylod)

Expand Down
86 changes: 86 additions & 0 deletions src/routes/responses/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { Hono } from "hono"
import { streamSSE } from "hono/streaming"

import { awaitApproval } from "~/lib/approval"
import { forwardError } from "~/lib/error"
import { checkRateLimit } from "~/lib/rate-limit"
import { state } from "~/lib/state"
import { createChatCompletions } from "~/services/copilot/create-chat-completions"
import {
createResponses,
type ResponsesPayload,
} from "~/services/copilot/create-responses"
import {
chatResultToResponsesResult,
isResponsesCapableModel,
responsesPayloadHints,
responsesPayloadToChatPayload,
streamChatAsResponsesEvents,
} from "~/services/copilot/responses-adapter"

export const responseRoutes = new Hono()

responseRoutes.post("/", async (c) => {
Comment thread
Copilot marked this conversation as resolved.
Resolved
try {
// same request guards as the chat route: rate limit + manual approval
await checkRateLimit(state)
if (state.manualApprove) await awaitApproval()

const body = (await c.req.json()) as unknown
if (typeof body !== "object" || body === null) {
return c.json({ error: "model is required" }, 400)
}
const payload = body as Record<string, unknown>
if (typeof payload.model !== "string" || payload.model.length === 0) {
return c.json({ error: "model is required" }, 400)
}
const model = payload.model

// responses-native models stream straight through
if (isResponsesCapableModel(model)) {
const response = await createResponses(
payload as ResponsesPayload,
responsesPayloadHints(payload),
)
const body = response.body
if (!body) {
return c.json({ error: "Empty response body" }, 502)
}

const contentType =
response.headers.get("content-type") ?? "application/json"

return c.body(body, 200, {
"content-type": contentType,
"cache-control": "no-cache",
})
}

// chat-only models are served by translating through Chat Completions
const chatPayload = responsesPayloadToChatPayload(payload)

if (payload.stream === true) {
const chatStream = (await createChatCompletions({
...chatPayload,
stream: true,
})) as AsyncIterable<{ data?: string | null }>

return streamSSE(c, async (stream) => {
for await (const event of streamChatAsResponsesEvents(
chatStream,
model,
)) {
await stream.writeSSE({ data: event.data })
}
})
}

const chatResult = await createChatCompletions(chatPayload)
const responsesResult = chatResultToResponsesResult(
chatResult as Parameters<typeof chatResultToResponsesResult>[0],
)
return c.json(responsesResult)
} catch (error) {
return await forwardError(c, error)
}
})
6 changes: 6 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { cors } from "hono/cors"
import { logger } from "hono/logger"

import { completionRoutes } from "./routes/chat-completions/route"
import { completionsRoutes } from "./routes/completions/route"
import { embeddingRoutes } from "./routes/embeddings/route"
import { messageRoutes } from "./routes/messages/route"
import { modelRoutes } from "./routes/models/route"
import { responseRoutes } from "./routes/responses/route"
import { tokenRoute } from "./routes/token/route"
import { usageRoute } from "./routes/usage/route"

Expand All @@ -17,15 +19,19 @@ server.use(cors())
server.get("/", (c) => c.text("Server running"))

server.route("/chat/completions", completionRoutes)
server.route("/completions", completionsRoutes)
server.route("/models", modelRoutes)
server.route("/embeddings", embeddingRoutes)
server.route("/responses", responseRoutes)
server.route("/usage", usageRoute)
server.route("/token", tokenRoute)

// Compatibility with tools that expect v1/ prefix
server.route("/v1/chat/completions", completionRoutes)
server.route("/v1/completions", completionsRoutes)
server.route("/v1/models", modelRoutes)
server.route("/v1/embeddings", embeddingRoutes)
server.route("/v1/responses", responseRoutes)

// Anthropic compatible endpoints
server.route("/v1/messages", messageRoutes)
96 changes: 96 additions & 0 deletions src/services/copilot/completions-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type {
CompletionsPayload,
ProxyCompletionResult,
} from "~/services/copilot/create-proxy-completions"

import { isNullish } from "~/lib/utils"
import {
type ChatCompletionChunk,
type ChatCompletionsPayload,
type ChatCompletionResponse,
} from "~/services/copilot/create-chat-completions"

/*
* Legacy completions surface for chat-capable models: translates the old
* prompt-based /v1/completions format through Chat Completions, so every
* usable model is reachable from legacy clients too.
*/

export const completionPayloadToChatPayload = (
payload: CompletionsPayload,
): ChatCompletionsPayload => {
// Array prompts mean independent prompts (or FIM prefix/suffix pairs) in
// the legacy contract and cannot be represented as a single chat message;
// the route rejects them before we get here.
if (typeof payload.prompt !== "string") {
throw new TypeError("chat translation requires a single string prompt")
}

const result: ChatCompletionsPayload = {
model: payload.model,
messages: [{ role: "user", content: payload.prompt }],
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

if (!isNullish(payload.max_tokens)) result.max_tokens = payload.max_tokens
if (!isNullish(payload.temperature)) {
result.temperature = payload.temperature
}
if (!isNullish(payload.top_p)) result.top_p = payload.top_p
if (!isNullish(payload.stop)) result.stop = payload.stop
if (!isNullish(payload.n)) result.n = payload.n

return result
}

export const chatResultToCompletionResult = (
chat: ChatCompletionResponse,
): ProxyCompletionResult => ({
id: chat.id,
object: "text_completion",
created: chat.created,
model: chat.model,
choices: chat.choices.map((choice) => ({
text: choice.message.content ?? "",
index: choice.index,
finish_reason: choice.finish_reason,
logprobs: null,
})),
usage:
chat.usage ?
{
prompt_tokens: chat.usage.prompt_tokens,
completion_tokens: chat.usage.completion_tokens,
total_tokens: chat.usage.total_tokens,
}
: undefined,
})

export async function* streamChatAsCompletionChunks(
chatStream: AsyncIterable<{ data?: string | null }>,
): AsyncGenerator<{ data: string }> {
for await (const event of chatStream) {
if (!event.data) continue
// skip the non-JSON SSE terminator line
if (!event.data.startsWith("{")) break
const chunk = JSON.parse(event.data) as ChatCompletionChunk
if (chunk.choices.length === 0) continue

yield {
data: JSON.stringify({
id: chunk.id,
object: "text_completion",
created: chunk.created,
model: chunk.model,
choices: chunk.choices.map((choice) => ({
text: choice.delta.content ?? "",
index: choice.index,
finish_reason: choice.finish_reason,
logprobs: null,
})),
}),
}
}

// emit the conventional SSE terminator line
yield { data: "[DO" + "NE]" }
}
Loading