음성 OSDK 애플리케이션 구성
브라우저에서 Foundry 사용자로 인증하고 실시간 모델과 WebSocket을 연결한 뒤 마이크 음성을 전송하고 모델 응답을 재생합니다. 온톨로지 도구는 사용자의 OAuth 토큰으로 실행합니다. 녹음이나 전사 전에는 참여자 고지와 필요한 동의를 받아야 하며 브라우저 권한만으로 이를 대신할 수 없습니다.
준비
@osdk/create-app으로 OAuth 구성이 포함된 TypeScript 앱을 만듭니다. Developer Console에는 unrestricted custom application이 필요합니다. 실시간 연결의 language-model-service:use-model 권한 범위는 unrestricted 앱에서만 사용할 수 있습니다. 플랫폼 인스턴스에 실시간 모델이 활성화되어 있어야 합니다. 이 예시는 gpt-realtime-2와 전사용 whisper-1을 사용하므로 두 모델의 접근 조건을 확인합니다.
애플리케이션에 OpenAI Agents SDK와 도구 입력 스키마에 사용할 zod를 설치합니다.
npm install @openai/agents zod
OAuth와 OSDK 클라이언트
생성 앱의 src/client.ts는 .env에서 index.html의 osdk-* 메타 태그로 주입한 설정을 읽습니다. scopes에 실시간 모델 권한을 추가하고 다른 범위는 앱의 작업에 맞게 지정합니다. 토큰에 허용되는 범위는 등록 앱과 사용자의 권한을 따릅니다.
import { createClient, type Client } from "@osdk/client";
import { createPublicOauthClient, type PublicOauthClient } from "@osdk/oauth";
function getMetaTagContent(tagName: string): string {
const elements = document.querySelectorAll(`meta[name="${tagName}"]`);
const element = elements.item(elements.length - 1);
const value = element ? element.getAttribute("content") : null;
if (value == null || value === "") {
throw new Error(`Meta tag ${tagName} not found or empty`);
}
return value;
}
export const foundryUrl = getMetaTagContent("osdk-foundryUrl");
const clientId = getMetaTagContent("osdk-clientId");
const redirectUrl = getMetaTagContent("osdk-redirectUrl");
const ontologyRid = getMetaTagContent("osdk-ontologyRid");
const scopes = [
"language-model-service:use-model",
// Other scopes your application requires, for example:
// "api:read-data",
// "api:use-ontologies-read",
];
export const auth: PublicOauthClient = createPublicOauthClient(
clientId,
foundryUrl,
redirectUrl,
{ scopes }
);
export const client: Client = createClient(foundryUrl, ontologyRid, auth);
export default client;
런타임에서 토큰이 없으면 로그인한 뒤 다시 가져옵니다.
import { auth } from "../client";
let token = await auth.getTokenOrUndefined();
if (!token) {
await auth.signIn();
token = await auth.getTokenOrUndefined();
}
if (!token) {
throw new Error("Unable to obtain access token. Please try again.");
}
Foundry 실시간 프록시
연결 주소는 다음과 같습니다. 호스트 자리는 실제 Foundry 환경을 뜻합니다.
wss://<your-foundry-domain>/language-model-service/ws/v1/open-ai/realtime?model=gpt-realtime-2
프록시는 플랫폼 인스턴스에 따라 OpenAI Direct 또는 Azure OpenAI로 연결하고 제공자의 기본 실시간 프로토콜을 유지합니다. 브라우저 WebSocket은 사용자 정의 Authorization 헤더를 설정할 수 없으므로 Bearer-<access-token>을 subprotocol에 넣습니다. 이 값은 개발자 도구에서 보이는 자격 증명이므로 로그, UI 문자열이나 다른 시스템으로 보내지 않습니다. 장시간 세션에서는 시작 시 토큰이 만료되어 새 토큰으로 재연결해야 할 수 있습니다.
다음 구성은 src/voice/session.ts에 둡니다. useInsecureApiKey는 OpenAI 키를 브라우저에서 사용하는 SDK 보호 검사를 끄는 옵션입니다. 여기서는 OpenAI 키를 제공하지 않고 Foundry subprotocol로 인증합니다. apiKey: "unused"는 SDK가 요구하는 비어 있지 않은 값일 뿐 인증 정보가 아닙니다.
import {
RealtimeAgent,
RealtimeSession,
OpenAIRealtimeWebSocket,
} from "@openai/agents/realtime";
import { foundryUrl } from "../client";
const PROXY_URL =
`wss://${new URL(foundryUrl).host}/language-model-service/ws/v1/open-ai/realtime` +
`?model=gpt-realtime-2`;
const CONNECT_TIMEOUT_MS = 10_000;
function createTransport(token: string): OpenAIRealtimeWebSocket {
return new OpenAIRealtimeWebSocket({
url: PROXY_URL,
useInsecureApiKey: true, // auth is via the subprotocol below, not OpenAI's apiKey
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createWebSocket: async ({ url }: { url: string }): Promise<any> => {
const ws = new WebSocket(url, [`Bearer-${token}`]);
ws.addEventListener("error", () => {
console.error("WebSocket connection failed (check network tab for details)");
});
ws.addEventListener("close", (ev) => {
// 1000 = normal close, do not surface as an error.
if (ev.code !== 1000) {
console.error(`WebSocket closed: ${ev.code}${ev.reason ? ` (${ev.reason})` : ""}`);
}
});
return ws;
},
});
}
function createSession(
agent: RealtimeAgent,
transport: OpenAIRealtimeWebSocket,
): RealtimeSession {
return new RealtimeSession(agent, {
transport,
model: "gpt-realtime-2",
tracingDisabled: true,
config: {
outputModalities: ["audio"],
audio: {
input: {
format: "pcm16",
transcription: { model: "whisper-1" },
turnDetection: {
type: "server_vad",
threshold: 0.8,
silenceDurationMs: 500,
prefixPaddingMs: 300,
},
},
output: { format: "pcm16" },
},
},
});
}
오디오 입출력은 PCM16이며 서버 VAD의 임계값은 0.8, 무음 시간은 500ms, 앞부분 패딩은 300ms입니다. 공급자 외부 추적은 tracingDisabled: true로 비활성화합니다.
connect()의 완료만으로 오디오 전송을 시작하지 않습니다. session.created 뒤 첫 session.updated를 받고 200ms 더 기다립니다. 10초 안에 준비되지 않으면 시간 초과 오류를 반환합니다.
function awaitSessionReady(session: RealtimeSession): Promise<RealtimeSession> {
return new Promise<RealtimeSession>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Connection timeout"));
}, CONNECT_TIMEOUT_MS);
let created = false;
let updatedCount = 0;
session.on("transport_event", (ev) => {
if (ev.type === "session.created") {
created = true;
}
if (ev.type === "session.updated" && created) {
updatedCount++;
if (updatedCount === 1) {
setTimeout(() => {
clearTimeout(timeout);
resolve(session);
}, 200);
}
}
});
session.connect({ apiKey: "unused" });
});
}
세 부분을 연결하면 준비된 세션을 반환하는 함수가 됩니다.
export async function startVoiceSession(
agent: RealtimeAgent,
token: string,
): Promise<RealtimeSession> {
const transport = createTransport(token);
const session = createSession(agent, transport);
return awaitSessionReady(session);
}
온톨로지 조회 도구
@openai/agents/realtime의 tool로 도구를 정의하고 RealtimeAgent의 tools에 등록합니다. 모델이 정보가 필요하다고 판단하면 도구의 execute를 호출합니다.
상품 조회 도구의 이름은 lookup_product이며 상품 이름을 문자열 매개변수 name으로 받습니다. zod의 z.object와 z.string으로 입력 스키마를 정의합니다.
다음 함수에는 애플리케이션의 상품 조회 함수 queryProducts를 전달합니다. 조회 함수는 사용자 OAuth 토큰으로 인증한 OSDK 클라이언트를 사용하며, 상품 이름을 받아 id, name, price, stock을 가진 결과 배열을 반환해야 합니다. 조회 오류는 호출자에게 전달합니다. 생성 SDK의 객체 유형과 속성은 각 온톨로지에서 정의한 이름을 사용합니다.
import { RealtimeAgent, tool } from "@openai/agents/realtime";
import { z } from "zod";
type ProductSummary = {
id: string;
name: string;
price: number;
stock: number;
};
export function createProductVoiceAgent(
queryProducts: (name: string) => Promise<ProductSummary[]>,
): RealtimeAgent {
const lookupProduct = tool({
name: "lookup_product",
description: "Look up a product by name. Use this when the user asks about a specific product.",
parameters: z.object({
name: z.string().describe("The product name to look up"),
}),
execute: async ({ name }) => JSON.stringify(await queryProducts(name)),
});
return new RealtimeAgent({
name: "Assistant",
instructions:
"You help users find products. When the user asks about a product, call the lookup_product tool.",
tools: [lookupProduct],
});
}
예를 들어 생성 SDK에 Product 객체 유형이 있다면 조회 함수에서 client(Product).where({ name: { $startsWith: name } }).fetchPage({ $pageSize: 5 })로 이름이 입력 문자열로 시작하는 상품을 최대 다섯 개 조회하고, 응답의 data에서 필요한 속성을 선택할 수 있습니다. createProductVoiceAgent(queryProducts)가 반환한 에이전트를 agent로 두고 앞서 얻은 토큰과 함께 startVoiceSession(agent, token)에 전달합니다. 모델에는 조회 함수가 반환한 속성만 전달됩니다.
온톨로지 쓰기가 필요하면 execute 안에서 액션을 호출합니다. 액션에는 일반 Foundry 권한과 입력 유효성 검사가 적용됩니다.
마이크와 재생 연결
마이크에서 입력받은 음성을 24kHz의 16비트 PCM으로 변환하여 청크마다 session.sendAudio()에 전달하고, 모델의 audio 이벤트로 받은 데이터를 재생 큐에 넣습니다. startMicCapture와 playbackQueue는 애플리케이션 개발자가 제공할 마이크 캡처 및 오디오 재생 구현입니다.
const session = await startVoiceSession(agent, token);
// Surface mid-session errors from the SDK. WebSocket-level transport errors
// (such as a failure to establish the connection) are logged to the console
// from the handlers in `createTransport`; if the connection never establishes,
// `startVoiceSession` rejects after `CONNECT_TIMEOUT_MS`.
session.on("error", (event) => {
console.error("Realtime session error:", event.error);
});
// Play back audio from the model
session.on("audio", (event) => {
playbackQueue.enqueue(event.data); // your playback implementation
});
// Clear playback when the model is interrupted (for example, the user starts speaking)
session.on("audio_interrupted", () => {
playbackQueue.clear();
});
// Send mic audio to the model as 16-bit PCM chunks at 24 kHz arrive
startMicCapture((pcm16) => session.sendAudio(pcm16)); // your mic capture implementation
// Optional: prompt the agent to greet the user immediately rather than
// waiting for the user to speak first.
session.sendMessage("(Session started. Greet the user briefly.)");
모델 권한 범위가 없으면 WebSocket 인증에서 거부됩니다. 비정상 종료는 전송 계층의 코드와 사유로 조사하되 토큰을 기록하지 않습니다. 연결 중 오류와 실행 중 오류를 구분하고 장시간 세션에서는 토큰 만료에 따른 재연결을 고려합니다. 실제 앱에는 필요한 온톨로지 조회 및 액션, 음성 입출력과 사용자 동의 절차를 연결합니다.