mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
Merge pull request #12841 from Kilo-Org/feat-stt-model-discovery
feat(vscode): discover speech-to-text models
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Discover available speech-to-text models from the Kilo Gateway while retaining offline fallback support and organization model restrictions.
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod"
|
||||
import { getKiloUrlFromToken } from "../auth/token.js"
|
||||
import { getDefaultHeaders, buildKiloHeaders } from "../headers.js"
|
||||
import { resolveKiloGatewayBaseUrl } from "./url.js"
|
||||
import { KILO_API_BASE, KILO_OPENROUTER_BASE, MODELS_FETCH_TIMEOUT_MS, PROMPTS, AI_SDK_PROVIDERS } from "./constants.js"
|
||||
|
||||
export type KiloModelsResult = {
|
||||
@@ -121,6 +122,16 @@ export type KiloImageModelsResult = {
|
||||
error?: { kind: "unauthorized" | "network" | "schema" | "http"; status?: number }
|
||||
}
|
||||
|
||||
export type KiloTranscriptionModel = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type KiloTranscriptionModelsResult = {
|
||||
models: KiloTranscriptionModel[]
|
||||
error?: { kind: "unauthorized" | "network" | "schema" | "http"; status?: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch image-capable models from Kilo API (OpenRouter-compatible endpoint).
|
||||
* Uses the same raw fetch as {@link fetchKiloModels} but keeps only models
|
||||
@@ -145,6 +156,52 @@ export async function fetchKiloImageModels(options?: {
|
||||
return { models }
|
||||
}
|
||||
|
||||
export async function fetchKiloTranscriptionModels(options?: {
|
||||
kilocodeToken?: string
|
||||
kilocodeOrganizationId?: string
|
||||
baseURL?: string
|
||||
}): Promise<KiloTranscriptionModelsResult> {
|
||||
const token = options?.kilocodeToken
|
||||
const organizationId = options?.kilocodeOrganizationId
|
||||
const url = new URL("transcription-models", resolveKiloGatewayBaseUrl({ baseURL: options?.baseURL, token }))
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
...getDefaultHeaders(),
|
||||
...buildKiloHeaders(undefined, { kilocodeOrganizationId: organizationId }),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
signal: AbortSignal.timeout(MODELS_FETCH_TIMEOUT_MS),
|
||||
}).catch((err: unknown) => err as Error)
|
||||
|
||||
if (response instanceof Error) return { models: [], error: { kind: "network" } }
|
||||
if (!response.ok) {
|
||||
const kind = response.status === 401 || response.status === 403 ? "unauthorized" : "http"
|
||||
return { models: [], error: { kind, status: response.status } }
|
||||
}
|
||||
|
||||
const json = await response.json().catch(() => null)
|
||||
if (!json || !Array.isArray(json.data)) return { models: [], error: { kind: "schema" } }
|
||||
|
||||
const data: unknown[] = json.data
|
||||
const models = data.filter(isTranscriptionModel).map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
}))
|
||||
if (models.length === 0) return { models: [], error: { kind: "schema" } }
|
||||
return { models }
|
||||
}
|
||||
|
||||
type TranscriptionModelResponse = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
function isTranscriptionModel(value: unknown): value is TranscriptionModelResponse {
|
||||
if (!value || typeof value !== "object") return false
|
||||
const model = value as Record<string, unknown>
|
||||
return typeof model.id === "string" && typeof model.name === "string"
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared raw fetch + validate used by both {@link fetchKiloModels} and {@link fetchKiloImageModels}.
|
||||
*/
|
||||
|
||||
@@ -41,6 +41,9 @@ export {
|
||||
fetchKiloImageModels,
|
||||
type KiloImageModel,
|
||||
type KiloImageModelsResult,
|
||||
fetchKiloTranscriptionModels,
|
||||
type KiloTranscriptionModel,
|
||||
type KiloTranscriptionModelsResult,
|
||||
} from "./api/models.js"
|
||||
export {
|
||||
EMPTY_KILO_EMBEDDING_MODEL_CATALOG,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Verifies fetchKiloModels typed result and 401 fallback behaviour.
|
||||
|
||||
import { test, expect } from "bun:test"
|
||||
import { fetchKiloModels } from "../../src/api/models.js"
|
||||
import { fetchKiloModels, fetchKiloTranscriptionModels } from "../../src/api/models.js"
|
||||
|
||||
const VALID_RESPONSE = JSON.stringify({
|
||||
data: [
|
||||
@@ -330,3 +330,56 @@ test("keeps image-output models with tools and drops models without tools", asyn
|
||||
expect(result.models["test/model-a"]).toBeDefined()
|
||||
expect(result.models["test/no-tools"]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("fetches and filters the transcription catalog", async () => {
|
||||
const orig = globalThis.fetch
|
||||
const calls: string[] = []
|
||||
stubFetch(async (input) => {
|
||||
calls.push(String(input))
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
id: "fish-audio/transcribe-1",
|
||||
name: "Fish Audio: Transcribe 1",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
)
|
||||
})
|
||||
|
||||
const result = await fetchKiloTranscriptionModels({ kilocodeToken: "token" })
|
||||
|
||||
;(globalThis as any).fetch = orig
|
||||
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.models).toEqual([
|
||||
{
|
||||
id: "fish-audio/transcribe-1",
|
||||
name: "Fish Audio: Transcribe 1",
|
||||
},
|
||||
])
|
||||
expect(calls[0]).toContain("/api/gateway/transcription-models")
|
||||
})
|
||||
|
||||
test("keeps organization catalog errors from silently falling back to personal models", async () => {
|
||||
const orig = globalThis.fetch
|
||||
const calls: string[] = []
|
||||
const headers: Headers[] = []
|
||||
stubFetch(async (input, init) => {
|
||||
calls.push(String(input))
|
||||
headers.push(new Headers(init?.headers))
|
||||
return new Response("Forbidden", { status: 403 })
|
||||
})
|
||||
|
||||
const result = await fetchKiloTranscriptionModels({ kilocodeToken: "token", kilocodeOrganizationId: "org-1" })
|
||||
|
||||
;(globalThis as any).fetch = orig
|
||||
|
||||
expect(result.models).toEqual([])
|
||||
expect(result.error?.kind).toBe("unauthorized")
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toContain("/api/gateway/transcription-models")
|
||||
expect(headers[0]?.get("X-KILOCODE-ORGANIZATIONID")).toBe("org-1")
|
||||
})
|
||||
|
||||
@@ -169,6 +169,8 @@ import type { ProjectRef, SessionRef, WorktreeRef } from "./agent-manager/projec
|
||||
import { indexingConsentStore, registeredProjects } from "./indexing-consent"
|
||||
import { fetchKiloEmbeddingModelCatalog } from "@kilocode/kilo-gateway"
|
||||
import { fetchImageModels } from "./image-generation/models"
|
||||
import { fetchSpeechToTextModels } from "./speech-to-text/catalog"
|
||||
import { SPEECH_TO_TEXT_MODELS } from "./speech-to-text/models"
|
||||
import { stopSessionProcesses } from "./kilo-provider/background-process"
|
||||
import { sandboxDefault, sandboxSessionMetadata } from "./shared/sandbox-session"
|
||||
import {
|
||||
@@ -1021,6 +1023,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
exportTranscript: (sessionID) => this.handleExportSessionTranscript(sessionID),
|
||||
copy: (text) => vscode.env.clipboard.writeText(text),
|
||||
openSessions: (ids) => this.trackOpenSessions(ids),
|
||||
speechToTextModels: () => this.fetchAndSendSpeechToTextModels(),
|
||||
})
|
||||
) {
|
||||
return
|
||||
@@ -2701,6 +2704,15 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.postMessage(message)
|
||||
}
|
||||
|
||||
private async fetchAndSendSpeechToTextModels(): Promise<void> {
|
||||
const result = await fetchSpeechToTextModels(this.connectionService, this.getWorkspaceDirectory())
|
||||
if (!result.ok) {
|
||||
this.postMessage({ type: "speechToTextModelsLoaded" as const, models: [...SPEECH_TO_TEXT_MODELS] })
|
||||
return
|
||||
}
|
||||
this.postMessage({ type: "speechToTextModelsLoaded" as const, models: result.models })
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed sessionStatusMap with current session statuses on connect.
|
||||
* Without this, the Settings panel (which has no tracked sessions) would see
|
||||
@@ -3944,6 +3956,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
disposeGlobal: () => this.disposeGlobal(),
|
||||
fetchAndSendProviders: () => this.fetchAndSendProviders(),
|
||||
fetchAndSendAgents: () => this.fetchAndSendAgents(),
|
||||
fetchAndSendSpeechToTextModels: () => this.fetchAndSendSpeechToTextModels(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ type Ctx = {
|
||||
exportTranscript: (sessionID: string) => Promise<void>
|
||||
copy: (text: string) => PromiseLike<void>
|
||||
openSessions: (ids: string[]) => void
|
||||
speechToTextModels: () => Promise<void>
|
||||
}
|
||||
|
||||
export async function routeEarlyMessage(
|
||||
@@ -64,6 +65,10 @@ export async function routeEarlyMessage(
|
||||
ctx.post(buildThroughputSettingMessage())
|
||||
return true
|
||||
}
|
||||
if (message.type === "requestSpeechToTextModels") {
|
||||
await ctx.speechToTextModels()
|
||||
return true
|
||||
}
|
||||
if (message.type === "requestBrowserSettings") {
|
||||
ctx.browserSettings()
|
||||
return true
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface AuthContext {
|
||||
disposeGlobal(): Promise<void>
|
||||
fetchAndSendProviders(): Promise<void>
|
||||
fetchAndSendAgents(): Promise<void>
|
||||
fetchAndSendSpeechToTextModels(): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,6 +138,11 @@ export async function handleSetOrganization(ctx: AuthContext, organizationId: st
|
||||
} catch (error) {
|
||||
console.error("[Kilo New] KiloProvider: Failed to refresh agents after org switch:", error)
|
||||
}
|
||||
try {
|
||||
await ctx.fetchAndSendSpeechToTextModels()
|
||||
} catch (error) {
|
||||
console.error("[Kilo New] KiloProvider: Failed to refresh speech-to-text models after org switch:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle profile refresh request. */
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { KiloConnectionService } from "../services/cli-backend/connection-service"
|
||||
import { getErrorMessage } from "../kilo-provider-utils"
|
||||
import { type SpeechToTextModelDef } from "./models"
|
||||
|
||||
const PATH = "/kilo/models/transcriptions"
|
||||
|
||||
type CatalogModel = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type SpeechToTextCatalogResult = { ok: true; models: SpeechToTextModelDef[] } | { ok: false; error: string }
|
||||
|
||||
export async function fetchSpeechToTextModels(
|
||||
connection: KiloConnectionService,
|
||||
dir: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SpeechToTextCatalogResult> {
|
||||
const cfg = connection.getServerConfig()
|
||||
if (!cfg) return fail("Not connected to the Kilo backend")
|
||||
|
||||
const auth = Buffer.from(`kilo:${cfg.password}`).toString("base64")
|
||||
const url = new URL(PATH, cfg.baseUrl)
|
||||
if (dir) url.searchParams.set("directory", dir)
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { signal, headers: { Authorization: `Basic ${auth}` } })
|
||||
if (!res.ok) return fail(`Failed to fetch speech-to-text models (HTTP ${res.status})`)
|
||||
|
||||
const models = parseSpeechToTextCatalog(await res.json())
|
||||
if (!models) return fail("Invalid speech-to-text model catalog")
|
||||
return { ok: true, models }
|
||||
} catch (err) {
|
||||
return fail(getErrorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
function fail(error: string): SpeechToTextCatalogResult {
|
||||
return { ok: false, error }
|
||||
}
|
||||
|
||||
export function parseSpeechToTextCatalog(body: unknown): SpeechToTextModelDef[] | undefined {
|
||||
if (!Array.isArray(body)) return undefined
|
||||
const models = body.filter(isCatalogModel).map(toModel)
|
||||
return models.length > 0 ? models : undefined
|
||||
}
|
||||
|
||||
function isCatalogModel(value: unknown): value is CatalogModel {
|
||||
if (!value || typeof value !== "object") return false
|
||||
const model = value as Record<string, unknown>
|
||||
return typeof model.id === "string" && typeof model.name === "string"
|
||||
}
|
||||
|
||||
function toModel(model: CatalogModel): SpeechToTextModelDef {
|
||||
const index = model.name.indexOf(":")
|
||||
const provider = index === -1 ? model.id.split("/", 1)[0] || "Kilo Gateway" : model.name.slice(0, index).trim()
|
||||
return {
|
||||
id: model.id,
|
||||
label: index === -1 ? model.name : model.name.slice(index + 1).trim(),
|
||||
provider,
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export async function transcribeSpeech(
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: model.id,
|
||||
model: input.model || model.id,
|
||||
input_audio: {
|
||||
data: input.data,
|
||||
format: input.format,
|
||||
|
||||
@@ -23,9 +23,11 @@ describe("speech-to-text availability", () => {
|
||||
})
|
||||
|
||||
it("normalizes configured and unknown transcription models", () => {
|
||||
expect(selectedSpeechToTextModel({ experimental: { speech_to_text_model: "google/chirp-3" } })).toBe(
|
||||
"google/chirp-3",
|
||||
)
|
||||
expect(
|
||||
selectedSpeechToTextModel({ experimental: { speech_to_text_model: "google/chirp-3" } }, [
|
||||
{ id: "google/chirp-3", label: "Chirp 3", provider: "Google" },
|
||||
]),
|
||||
).toBe("google/chirp-3")
|
||||
expect(selectedSpeechToTextModel({ experimental: { speech_to_text_model: "unknown/model" } })).toBe(
|
||||
DEFAULT_SPEECH_TO_TEXT_MODEL.id,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { parseSpeechToTextCatalog } from "../../src/speech-to-text/catalog"
|
||||
import { DEFAULT_SPEECH_TO_TEXT_MODEL } from "../../src/speech-to-text/models"
|
||||
|
||||
describe("speech-to-text discovery", () => {
|
||||
it("keeps transcription catalog metadata authoritative and exposes additions", () => {
|
||||
const models = parseSpeechToTextCatalog([
|
||||
{
|
||||
id: "fish-audio/transcribe-1",
|
||||
name: "Fish Audio: Transcribe 1",
|
||||
},
|
||||
{
|
||||
id: "openai/gpt-4o-mini-transcribe",
|
||||
name: "OpenAI: GPT-4o Mini Transcribe",
|
||||
},
|
||||
{
|
||||
id: "openai/whisper-1",
|
||||
name: "Whisper 1",
|
||||
},
|
||||
])
|
||||
|
||||
expect(models).toEqual([
|
||||
{ id: "fish-audio/transcribe-1", label: "Transcribe 1", provider: "Fish Audio" },
|
||||
{
|
||||
id: "openai/gpt-4o-mini-transcribe",
|
||||
label: "GPT-4o Mini Transcribe",
|
||||
provider: "OpenAI",
|
||||
},
|
||||
{ id: "openai/whisper-1", label: "Whisper 1", provider: "openai" },
|
||||
])
|
||||
})
|
||||
|
||||
it("rejects empty or malformed catalogs so callers can use the static fallback", () => {
|
||||
expect(parseSpeechToTextCatalog([])).toBeUndefined()
|
||||
expect(parseSpeechToTextCatalog({ data: [] })).toBeUndefined()
|
||||
expect(DEFAULT_SPEECH_TO_TEXT_MODEL.id).toBe("openai/whisper-large-v3-turbo")
|
||||
})
|
||||
})
|
||||
@@ -21,6 +21,7 @@ import { useProvider } from "../src/context/provider"
|
||||
import { useConfig } from "../src/context/config"
|
||||
import { canUseSpeechToText, selectedSpeechToTextModel } from "../src/components/speech-to-text/availability"
|
||||
import { useSpeechToText } from "../src/components/speech-to-text/useSpeechToText"
|
||||
import { useSpeechToTextModels } from "../src/context/speech-to-text-models"
|
||||
import {
|
||||
getDirectory,
|
||||
getFilename,
|
||||
@@ -111,8 +112,9 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
|
||||
const provider = useProvider()
|
||||
const { config } = useConfig()
|
||||
const speech = useSpeechToText(vscode, server, { t })
|
||||
const speechModels = useSpeechToTextModels()
|
||||
const canUseSpeech = () => canUseSpeechToText(config(), provider.authStates())
|
||||
const speechModel = () => selectedSpeechToTextModel(config())
|
||||
const speechModel = () => selectedSpeechToTextModel(config(), speechModels.models())
|
||||
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent)
|
||||
const sendAllKeybind = () =>
|
||||
isMac ? t("agentManager.review.sendAllShortcut.mac") : t("agentManager.review.sendAllShortcut.other")
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
import { useLanguage } from "../src/context/language"
|
||||
import { useImageAttachments, type ImageAttachment } from "../src/hooks/useImageAttachments"
|
||||
import { useSpeechToText } from "../src/components/speech-to-text/useSpeechToText"
|
||||
import { useSpeechToTextModels } from "../src/context/speech-to-text-models"
|
||||
import { createSpeechShortcut } from "../src/components/speech-to-text/shortcut"
|
||||
import { convertToMentionPath } from "../src/utils/path-mentions"
|
||||
import { insertSpacedText } from "../src/components/chat/prompt-input-utils"
|
||||
@@ -131,8 +132,9 @@ export const NewWorktreeDialog: Component<{
|
||||
const sandboxRequestID = crypto.randomUUID()
|
||||
const sandboxVisible = () => features().sandboxControls && globalConfig().sandbox?.enabled === true
|
||||
const speech = useSpeechToText(vscode, server, { t })
|
||||
const speechModels = useSpeechToTextModels()
|
||||
const canUseSpeech = () => canUseSpeechToText(config(), provider.authStates())
|
||||
const speechModel = () => selectedSpeechToTextModel(config())
|
||||
const speechModel = () => selectedSpeechToTextModel(config(), speechModels.models())
|
||||
let prior: string | null = null
|
||||
let request: string | undefined
|
||||
const cancel = () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ import type { DiffViewerNotice } from "../src/types/messages/extension-messages"
|
||||
import { DiffPickerHeader } from "./DiffPickerHeader"
|
||||
import { BaseBranchPicker } from "./BaseBranchPicker"
|
||||
import { SpeechToTextPrewarm } from "../src/components/speech-to-text/SpeechToTextPrewarm"
|
||||
import { SpeechToTextModelsProvider } from "../src/context/speech-to-text-models"
|
||||
|
||||
const NOTICE_KEYS: Record<DiffViewerNotice, string> = {
|
||||
"snapshots-disabled": "diffViewer.notice.snapshotsDisabled",
|
||||
@@ -306,8 +307,10 @@ export const DiffViewerApp: Component = () => {
|
||||
<ServerProvider>
|
||||
<ProviderProvider>
|
||||
<ConfigProvider>
|
||||
<SpeechToTextPrewarm />
|
||||
<DiffViewerShell />
|
||||
<SpeechToTextModelsProvider>
|
||||
<SpeechToTextPrewarm />
|
||||
<DiffViewerShell />
|
||||
</SpeechToTextModelsProvider>
|
||||
</ConfigProvider>
|
||||
</ProviderProvider>
|
||||
</ServerProvider>
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useProvider } from "../src/context/provider"
|
||||
import { useConfig } from "../src/context/config"
|
||||
import { canUseSpeechToText, selectedSpeechToTextModel } from "../src/components/speech-to-text/availability"
|
||||
import { useSpeechToText } from "../src/components/speech-to-text/useSpeechToText"
|
||||
import { useSpeechToTextModels } from "../src/context/speech-to-text-models"
|
||||
import { FileTree } from "./FileTree"
|
||||
import { treeOrder } from "./file-tree-utils"
|
||||
import { getDirectory, getFilename, lineCount, sanitizeReviewComments, type ReviewComment } from "./review-comments"
|
||||
@@ -113,8 +114,9 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
|
||||
const provider = useProvider()
|
||||
const { config } = useConfig()
|
||||
const speech = useSpeechToText(vscode, server, { t })
|
||||
const speechModels = useSpeechToTextModels()
|
||||
const canUseSpeech = () => canUseSpeechToText(config(), provider.authStates())
|
||||
const speechModel = () => selectedSpeechToTextModel(config())
|
||||
const speechModel = () => selectedSpeechToTextModel(config(), speechModels.models())
|
||||
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent)
|
||||
const sendAllKeybind = () =>
|
||||
isMac ? t("agentManager.review.sendAllShortcut.mac") : t("agentManager.review.sendAllShortcut.other")
|
||||
|
||||
@@ -34,6 +34,7 @@ import { hasGitChangesMention } from "../../hooks/git-changes-context-utils"
|
||||
import { useSlashCommand } from "../../hooks/useSlashCommand"
|
||||
import { useGhostText } from "../../hooks/useGhostText"
|
||||
import { useSpeechToText } from "../speech-to-text/useSpeechToText"
|
||||
import { useSpeechToTextModels } from "../../context/speech-to-text-models"
|
||||
import { createSpeechShortcut } from "../speech-to-text/shortcut"
|
||||
import { useImageAttachments, type ImageAttachment } from "../../hooks/useImageAttachments"
|
||||
import { convertToMentionPath } from "../../utils/path-mentions"
|
||||
@@ -348,6 +349,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const ghost = useGhostText(vscode, text, () => server.isConnected())
|
||||
const speech = useSpeechToText(vscode, server, language)
|
||||
const speechModels = useSpeechToTextModels()
|
||||
|
||||
const replaceReviewComments = (next: ReviewComment[]) => {
|
||||
setReviewComments(next)
|
||||
@@ -511,7 +513,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
)
|
||||
const isDisabled = () => !server.isConnected()
|
||||
const canUseSpeech = () => canUseSpeechToText(config(), provider.authStates())
|
||||
const speechModel = () => selectedSpeechToTextModel(config())
|
||||
const speechModel = () => selectedSpeechToTextModel(config(), speechModels.models())
|
||||
const hasInput = () => text().trim().length > 0 || imageAttach.images().length > 0 || reviewComments().length > 0
|
||||
const canSend = () =>
|
||||
!isDisabled() &&
|
||||
|
||||
@@ -7,13 +7,14 @@ import { useConfig } from "../../context/config"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { useProvider } from "../../context/provider"
|
||||
import { useSession } from "../../context/session"
|
||||
import { useSpeechToTextModels } from "../../context/speech-to-text-models"
|
||||
import { parseModelString } from "../../../../src/shared/provider-model"
|
||||
import { ModelSelectorBase } from "../shared/ModelSelector"
|
||||
import { ThinkingSelectorBase } from "../shared/ThinkingSelector"
|
||||
import SettingsRow from "./SettingsRow"
|
||||
import { DEFAULT_SPEECH_TO_TEXT_MODEL } from "../../../../src/speech-to-text/models"
|
||||
import { hasSpeechToTextAccess, selectedSpeechToTextModel } from "../speech-to-text/availability"
|
||||
import { SPEECH_TO_TEXT_MODEL_OPTIONS } from "../speech-to-text/model-selector"
|
||||
import { speechToTextModelOptions } from "../speech-to-text/model-selector"
|
||||
import { AUTOCOMPLETE_SELECTOR_MODELS, getAutocompleteSelection } from "./autocomplete-model-selector"
|
||||
|
||||
const ModelsTab: Component = () => {
|
||||
@@ -21,6 +22,7 @@ const ModelsTab: Component = () => {
|
||||
const language = useLanguage()
|
||||
const provider = useProvider()
|
||||
const session = useSession()
|
||||
const speechModels = useSpeechToTextModels()
|
||||
|
||||
const autocompleteProvider = () => {
|
||||
const v = settings()["autocomplete.provider"]
|
||||
@@ -42,8 +44,9 @@ const ModelsTab: Component = () => {
|
||||
}
|
||||
|
||||
const subagentModel = createMemo(() => parseModelString(config().subagent_model ?? undefined))
|
||||
const speechModel = createMemo(() => selectedSpeechToTextModel(config()))
|
||||
const speechOption = createMemo(() => SPEECH_TO_TEXT_MODEL_OPTIONS.find((item) => item.value === speechModel()))
|
||||
const speechModel = createMemo(() => selectedSpeechToTextModel(config(), speechModels.models()))
|
||||
const speechOptions = createMemo(() => speechToTextModelOptions(speechModels.models()))
|
||||
const speechOption = createMemo(() => speechOptions().find((item) => item.value === speechModel()))
|
||||
const kiloReady = createMemo(() => hasSpeechToTextAccess(config(), provider.authStates()))
|
||||
const variantKey = createMemo(() => config().subagent_model ?? undefined)
|
||||
const subagentVariants = createMemo(() => Object.keys(provider.findModel(subagentModel())?.variants ?? {}))
|
||||
@@ -190,7 +193,7 @@ const ModelsTab: Component = () => {
|
||||
inactive={kiloReady()}
|
||||
>
|
||||
<Select
|
||||
options={SPEECH_TO_TEXT_MODEL_OPTIONS}
|
||||
options={speechOptions()}
|
||||
current={speechOption()}
|
||||
value={(item) => item.value}
|
||||
label={(item) => `${item.label} (${item.provider})`}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { KILO_PROVIDER_ID } from "../../../../src/shared/provider-model"
|
||||
import { getSpeechToTextModel } from "../../../../src/speech-to-text/models"
|
||||
import {
|
||||
DEFAULT_SPEECH_TO_TEXT_MODEL,
|
||||
SPEECH_TO_TEXT_MODELS,
|
||||
type SpeechToTextModelDef,
|
||||
} from "../../../../src/speech-to-text/models"
|
||||
|
||||
type Cfg = {
|
||||
enabled_providers?: string[]
|
||||
@@ -21,6 +25,10 @@ export function canUseSpeechToText(cfg: Cfg, auth: Readonly<Record<string, AuthS
|
||||
return hasSpeechToTextAccess(cfg, auth)
|
||||
}
|
||||
|
||||
export function selectedSpeechToTextModel(cfg: Cfg): string {
|
||||
return getSpeechToTextModel(cfg.experimental?.speech_to_text_model).id
|
||||
export function selectedSpeechToTextModel(
|
||||
cfg: Cfg,
|
||||
models: readonly SpeechToTextModelDef[] = SPEECH_TO_TEXT_MODELS,
|
||||
): string {
|
||||
const id = cfg.experimental?.speech_to_text_model
|
||||
return models.find((model) => model.id === id)?.id ?? models[0]?.id ?? DEFAULT_SPEECH_TO_TEXT_MODEL.id
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SPEECH_TO_TEXT_MODELS } from "../../../../src/speech-to-text/models"
|
||||
import type { SpeechToTextModelDef } from "../../../../src/speech-to-text/models"
|
||||
|
||||
export type SpeechToTextModelOption = {
|
||||
value: string
|
||||
@@ -6,8 +6,10 @@ export type SpeechToTextModelOption = {
|
||||
provider: string
|
||||
}
|
||||
|
||||
export const SPEECH_TO_TEXT_MODEL_OPTIONS: SpeechToTextModelOption[] = SPEECH_TO_TEXT_MODELS.map((model) => ({
|
||||
value: model.id,
|
||||
label: model.label,
|
||||
provider: model.provider,
|
||||
}))
|
||||
export function speechToTextModelOptions(models: readonly SpeechToTextModelDef[]): SpeechToTextModelOption[] {
|
||||
return models.map((model) => ({
|
||||
value: model.id,
|
||||
label: model.label,
|
||||
provider: model.provider,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { NotificationsProvider } from "./notifications"
|
||||
import { FeedbackProvider } from "./feedback"
|
||||
import { KiloEmbeddingModelsProvider } from "./kilo-embedding-models"
|
||||
import { ImageModelsProvider } from "./image-models"
|
||||
import { SpeechToTextModelsProvider } from "./speech-to-text-models"
|
||||
import { SpeechToTextPrewarm } from "../components/speech-to-text/SpeechToTextPrewarm"
|
||||
|
||||
type MermaidImageEvent = CustomEvent<{ dataUrl: string; filename: string }>
|
||||
@@ -77,9 +78,11 @@ const Session: ParentComponent = (props) => (
|
||||
<IndexingProvider>
|
||||
<KiloEmbeddingModelsProvider>
|
||||
<ImageModelsProvider>
|
||||
<NotificationsProvider>
|
||||
<SessionProvider>{props.children}</SessionProvider>
|
||||
</NotificationsProvider>
|
||||
<SpeechToTextModelsProvider>
|
||||
<NotificationsProvider>
|
||||
<SessionProvider>{props.children}</SessionProvider>
|
||||
</NotificationsProvider>
|
||||
</SpeechToTextModelsProvider>
|
||||
</ImageModelsProvider>
|
||||
</KiloEmbeddingModelsProvider>
|
||||
</IndexingProvider>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createContext, createSignal, onCleanup, useContext, type Accessor, type ParentComponent } from "solid-js"
|
||||
import { SPEECH_TO_TEXT_MODELS, type SpeechToTextModelDef } from "../../../src/speech-to-text/models"
|
||||
import { useVSCode } from "./vscode"
|
||||
import type { ExtensionMessage } from "../types/messages"
|
||||
|
||||
export type SpeechToTextModelsContextValue = {
|
||||
models: Accessor<readonly SpeechToTextModelDef[]>
|
||||
}
|
||||
|
||||
export const SpeechToTextModelsContext = createContext<SpeechToTextModelsContextValue>({
|
||||
models: () => SPEECH_TO_TEXT_MODELS,
|
||||
})
|
||||
|
||||
export const SpeechToTextModelsProvider: ParentComponent = (props) => {
|
||||
const vscode = useVSCode()
|
||||
const [models, setModels] = createSignal<readonly SpeechToTextModelDef[]>([...SPEECH_TO_TEXT_MODELS])
|
||||
const request = () => vscode.postMessage({ type: "requestSpeechToTextModels" })
|
||||
const unsubscribe = vscode.onMessage((message: ExtensionMessage) => {
|
||||
if (message.type !== "speechToTextModelsLoaded") return
|
||||
setModels(message.models)
|
||||
})
|
||||
|
||||
request()
|
||||
const retry = setTimeout(request, 3000)
|
||||
onCleanup(() => clearTimeout(retry))
|
||||
onCleanup(unsubscribe)
|
||||
|
||||
return <SpeechToTextModelsContext.Provider value={{ models }}>{props.children}</SpeechToTextModelsContext.Provider>
|
||||
}
|
||||
|
||||
export function useSpeechToTextModels(): SpeechToTextModelsContextValue {
|
||||
return useContext(SpeechToTextModelsContext)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import type { PermissionRequest } from "./permissions"
|
||||
import type { AnacondaDesktopExtensionMessage } from "../../../../src/shared/anaconda-desktop-messages"
|
||||
import type { QuestionRequest, SuggestionRequest, TodoItem } from "./questions"
|
||||
import type { ModelSelection, Provider, ProviderAuthState } from "./providers"
|
||||
import type { SpeechToTextModelDef } from "../../../../src/speech-to-text/models"
|
||||
import type { AgentInfo, AgentRequirementResult, SkillInfo, SlashCommandInfo } from "./agents"
|
||||
import type {
|
||||
BrowserSettings,
|
||||
@@ -383,6 +384,11 @@ export interface ImageModelsLoadedMessage {
|
||||
models: Array<{ id: string; name: string; description?: string }>
|
||||
}
|
||||
|
||||
export interface SpeechToTextModelsLoadedMessage {
|
||||
type: "speechToTextModelsLoaded"
|
||||
models: SpeechToTextModelDef[]
|
||||
}
|
||||
|
||||
export interface ProvidersLoadedMessage {
|
||||
type: "providersLoaded"
|
||||
providers: Record<string, Provider>
|
||||
@@ -1301,6 +1307,7 @@ export type ExtensionMessage =
|
||||
| ChatSettingsLoadedMessage
|
||||
| KiloEmbeddingModelsLoadedMessage
|
||||
| ImageModelsLoadedMessage
|
||||
| SpeechToTextModelsLoadedMessage
|
||||
| ProvidersLoadedMessage
|
||||
| AgentsLoadedMessage
|
||||
| SkillsLoadedMessage
|
||||
|
||||
@@ -517,6 +517,10 @@ export interface RequestImageModelsMessage {
|
||||
type: "requestImageModels"
|
||||
}
|
||||
|
||||
export interface RequestSpeechToTextModelsMessage {
|
||||
type: "requestSpeechToTextModels"
|
||||
}
|
||||
|
||||
export interface OpenSettingsTabRequest {
|
||||
type: "openSettingsTab"
|
||||
tab: string
|
||||
@@ -1583,6 +1587,7 @@ export type WebviewMessage =
|
||||
| AgentManagerTerminalDestinationSelectedRequest
|
||||
| AgentManagerTerminalResizeRequest
|
||||
| RequestImageModelsMessage
|
||||
| RequestSpeechToTextModelsMessage
|
||||
|
||||
// ============================================
|
||||
// VS Code API type
|
||||
|
||||
@@ -218,6 +218,11 @@ export const ImageModel = Schema.Struct({
|
||||
description: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
export const TranscriptionModel = Schema.Struct({
|
||||
id: Schema.String,
|
||||
name: Schema.String,
|
||||
})
|
||||
|
||||
const UnknownRecord = Schema.Record(Schema.String, Schema.Unknown)
|
||||
|
||||
export const CloudMessage = Schema.StructWithRest(
|
||||
@@ -272,6 +277,7 @@ export const KiloGatewayPaths = {
|
||||
edit: `${root}/edit`,
|
||||
audioTranscriptions: `${root}/audio/transcriptions`,
|
||||
imageModels: `${root}/models/images`,
|
||||
transcriptionModels: `${root}/models/transcriptions`,
|
||||
notifications: `${root}/notifications`,
|
||||
organization: `${root}/organization`,
|
||||
clawStatus: `${root}/claw/status`,
|
||||
@@ -366,6 +372,17 @@ export const KiloGatewayApi = HttpApi.make("kilo")
|
||||
description: "List image-capable models from the Kilo Gateway OpenRouter passthrough",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("transcriptionModels", KiloGatewayPaths.transcriptionModels, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(TranscriptionModel), "Speech-to-text model list"),
|
||||
error: [HttpApiError.BadRequest, HttpApiError.Unauthorized],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "kilo.models.transcriptions",
|
||||
summary: "Speech-to-text models",
|
||||
description: "List transcription-capable models from the Kilo Gateway catalog",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.get("notifications", KiloGatewayPaths.notifications, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: described(Schema.Array(Notification), "Notifications list"),
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
fetchCloudSession,
|
||||
fetchCloudSessionForImport,
|
||||
fetchKiloImageModels,
|
||||
fetchKiloTranscriptionModels,
|
||||
getCloudSessions,
|
||||
getOrganizationId,
|
||||
getToken,
|
||||
@@ -614,6 +615,29 @@ export const kiloGatewayHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilo",
|
||||
return result.models
|
||||
})
|
||||
|
||||
const transcriptionModels = Effect.fn("KiloGatewayHttpApi.transcriptionModels")(function* () {
|
||||
const info = yield* proxyAuth()
|
||||
if (!info.auth) return yield* Effect.fail(new HttpApiError.Unauthorized({}))
|
||||
if (!info.token) return yield* Effect.fail(new HttpApiError.Unauthorized({}))
|
||||
|
||||
const result = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
fetchKiloTranscriptionModels({
|
||||
kilocodeToken: info.token,
|
||||
kilocodeOrganizationId: info.organizationId,
|
||||
}),
|
||||
catch: () => new HttpApiError.BadRequest({}),
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
const err =
|
||||
result.error.kind === "unauthorized" ? new HttpApiError.Unauthorized({}) : new HttpApiError.BadRequest({})
|
||||
return yield* Effect.fail(err)
|
||||
}
|
||||
|
||||
return result.models
|
||||
})
|
||||
|
||||
return handlers
|
||||
.handle("profile", profile)
|
||||
.handle("authStatus", authStatus)
|
||||
@@ -622,6 +646,7 @@ export const kiloGatewayHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilo",
|
||||
.handle("edit", edit)
|
||||
.handle("audioTranscriptions", audioTranscriptions)
|
||||
.handle("imageModels", imageModels)
|
||||
.handle("transcriptionModels", transcriptionModels)
|
||||
.handle("notifications", notifications)
|
||||
.handle("organization", organization)
|
||||
.handle("clawStatus", clawStatus)
|
||||
|
||||
@@ -389,6 +389,7 @@ export const kiloScenarios: Scenario[] = [
|
||||
.status(401),
|
||||
http.protected.get("/kilo/notifications", "kilo.notifications").json(200, array),
|
||||
http.protected.get("/kilo/models/images", "kilo.models.images").probe({ path: "/path" }).status(401),
|
||||
http.protected.get("/kilo/models/transcriptions", "kilo.models.transcriptions").probe({ path: "/path" }).status(401),
|
||||
http.protected
|
||||
.post("/kilo/organization", "kilo.organization.set")
|
||||
.at((ctx) => ({ path: "/kilo/organization", headers: ctx.headers(), body: { organizationId: null } }))
|
||||
|
||||
@@ -237,4 +237,13 @@ describe("Kilo PublicApi OpenAPI contract", () => {
|
||||
const schema = body?.content?.["application/json"]?.schema
|
||||
expect(schema?.properties?.prompt).toEqual({ type: "string" })
|
||||
})
|
||||
|
||||
test("documents the transcription model catalog route", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi)
|
||||
const route = spec.paths[KiloGatewayPaths.transcriptionModels]?.get
|
||||
const query = (route?.parameters as Parameter[] | undefined)?.map((item) => item.name)
|
||||
|
||||
expect(query).toEqual(["directory", "workspace"])
|
||||
expect(route?.responses?.["200"]?.content?.["application/json"]?.schema).toMatchObject({ type: "array" })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -211,6 +211,8 @@ import type {
|
||||
KiloFimResponses,
|
||||
KiloModelsImagesErrors,
|
||||
KiloModelsImagesResponses,
|
||||
KiloModelsTranscriptionsErrors,
|
||||
KiloModelsTranscriptionsResponses,
|
||||
KiloModesErrors,
|
||||
KiloModesResponses,
|
||||
KiloNotificationsErrors,
|
||||
@@ -6855,6 +6857,40 @@ export class Models extends HeyApiClient {
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Speech-to-text models
|
||||
*
|
||||
* List transcription-capable models from the Kilo Gateway catalog
|
||||
*/
|
||||
public transcriptions<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).get<
|
||||
KiloModelsTranscriptionsResponses,
|
||||
KiloModelsTranscriptionsErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/kilo/models/transcriptions",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Organization extends HeyApiClient {
|
||||
|
||||
@@ -12182,6 +12182,38 @@ export type KiloModelsImagesResponses = {
|
||||
|
||||
export type KiloModelsImagesResponse = KiloModelsImagesResponses[keyof KiloModelsImagesResponses]
|
||||
|
||||
export type KiloModelsTranscriptionsData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
url: "/kilo/models/transcriptions"
|
||||
}
|
||||
|
||||
export type KiloModelsTranscriptionsErrors = {
|
||||
/**
|
||||
* BadRequest | InvalidRequestError
|
||||
*/
|
||||
400: EffectHttpApiErrorBadRequest | InvalidRequestError
|
||||
}
|
||||
|
||||
export type KiloModelsTranscriptionsError = KiloModelsTranscriptionsErrors[keyof KiloModelsTranscriptionsErrors]
|
||||
|
||||
export type KiloModelsTranscriptionsResponses = {
|
||||
/**
|
||||
* Speech-to-text model list
|
||||
*/
|
||||
200: Array<{
|
||||
id: string
|
||||
name: string
|
||||
}>
|
||||
}
|
||||
|
||||
export type KiloModelsTranscriptionsResponse =
|
||||
KiloModelsTranscriptionsResponses[keyof KiloModelsTranscriptionsResponses]
|
||||
|
||||
export type KiloNotificationsData = {
|
||||
body?: never
|
||||
path?: never
|
||||
|
||||
@@ -13953,6 +13953,81 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/kilo/models/transcriptions": {
|
||||
"get": {
|
||||
"tags": ["kilo"],
|
||||
"operationId": "kilo.models.transcriptions",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "directory",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "workspace",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Speech-to-text model list",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "name"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"description": "Speech-to-text model list"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "BadRequest | InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/effect_HttpApiError_BadRequest"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestError"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "List transcription-capable models from the Kilo Gateway catalog",
|
||||
"summary": "Speech-to-text models",
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.kilo.models.transcriptions({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/kilo/notifications": {
|
||||
"get": {
|
||||
"tags": ["kilo"],
|
||||
|
||||
Reference in New Issue
Block a user