mirror of
https://github.com/openai/codex.git
synced 2026-10-30 19:18:49 +00:00
feat: move wasm sampling into rust
This commit is contained in:
Generated
+2
@@ -2927,12 +2927,14 @@ dependencies = [
|
||||
name = "codex-wasm-harness"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"codex-core",
|
||||
"js-sys",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -339,6 +339,7 @@ vt100 = "0.16.2"
|
||||
walkdir = "2.5.0"
|
||||
wasm-bindgen = "0.2.108"
|
||||
wasm-bindgen-futures = "0.4.58"
|
||||
web-sys = "0.3.85"
|
||||
webbrowser = "1.0"
|
||||
which = "8"
|
||||
wildmatch = "2.6.1"
|
||||
|
||||
@@ -12,12 +12,23 @@ path = "src/lib.rs"
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
real-core = ["dep:codex-core"]
|
||||
|
||||
[dependencies]
|
||||
codex-core = { workspace = true, optional = true }
|
||||
js-sys = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
wasm-bindgen = { workspace = true }
|
||||
wasm-bindgen-futures = { workspace = true }
|
||||
web-sys = { workspace = true, features = [
|
||||
"Headers",
|
||||
"RequestInit",
|
||||
"RequestMode",
|
||||
"Response",
|
||||
"Window",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
|
||||
@@ -9,10 +9,10 @@ establishes the intended browser API shape:
|
||||
- stream Codex-shaped turn events back to the page; and
|
||||
- resolve after a `turn_complete` event.
|
||||
|
||||
The sampler is currently a JavaScript callback so the browser demo can keep
|
||||
network and credential policy outside the WASM bundle. The demo page uses a
|
||||
deterministic local sampler by default, or a direct Responses API request when
|
||||
the user enters an API key.
|
||||
The sampler is currently implemented in Rust/WASM. The demo page uses a
|
||||
deterministic local response by default, or passes the user's API key into the
|
||||
WASM facade so Rust can make a direct browser `fetch` request to the Responses
|
||||
API.
|
||||
|
||||
The API key field is for local prototype use only: it stores the key in browser
|
||||
`localStorage` and sends it directly from the page. A production browser
|
||||
@@ -23,6 +23,11 @@ The next step is to replace the callback boundary with a real model transport
|
||||
and then wire the facade to the Codex turn loop after host services are
|
||||
injectable.
|
||||
|
||||
The `real-core` feature is an explicit compile probe for depending on
|
||||
`codex-core`. It currently does not build for `wasm32-unknown-unknown`; the
|
||||
first blocker is native Tokio/Mio networking pulled through the host-heavy
|
||||
`codex-core` dependency graph.
|
||||
|
||||
## Current Limitations
|
||||
|
||||
This is a boundary prototype, not a port of `codex-core` yet.
|
||||
|
||||
@@ -208,100 +208,31 @@
|
||||
const apiKeyStatus = document.querySelector("#api-key-status");
|
||||
const clearApiKeyButton = document.querySelector("#clear-api-key");
|
||||
const API_KEY_STORAGE_KEY = "codexWasmHarness.apiKey";
|
||||
const RESPONSE_MODEL = "gpt-5.1";
|
||||
const codex = new BrowserCodex(readStoredApiKey());
|
||||
|
||||
apiKeyInput.value = readStoredApiKey();
|
||||
updateApiKeyStatus();
|
||||
|
||||
apiKeyInput.addEventListener("input", () => {
|
||||
writeStoredApiKey(apiKeyInput.value.trim());
|
||||
const apiKey = apiKeyInput.value.trim();
|
||||
writeStoredApiKey(apiKey);
|
||||
codex.set_api_key(apiKey);
|
||||
updateApiKeyStatus();
|
||||
});
|
||||
|
||||
clearApiKeyButton.addEventListener("click", () => {
|
||||
apiKeyInput.value = "";
|
||||
writeStoredApiKey("");
|
||||
codex.set_api_key("");
|
||||
updateApiKeyStatus();
|
||||
});
|
||||
|
||||
const sampler = async ({ prompt, instructions }) => {
|
||||
const apiKey = readStoredApiKey();
|
||||
if (apiKey) {
|
||||
return sampleWithResponsesApi({ apiKey, prompt, instructions });
|
||||
}
|
||||
|
||||
if (prompt.toLowerCase().includes("hello world")) {
|
||||
return {
|
||||
message:
|
||||
'Here is a minimal hello world example:\\n\\n```js\\nconsole.log("hello world");\\n```',
|
||||
};
|
||||
}
|
||||
return {
|
||||
message: `Browser sampler placeholder response for: ${prompt}`,
|
||||
};
|
||||
};
|
||||
|
||||
const codex = new BrowserCodex(sampler);
|
||||
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
events.replaceChildren();
|
||||
await codex.submit_turn(input.value, renderEvent);
|
||||
});
|
||||
|
||||
async function sampleWithResponsesApi({ apiKey, prompt, instructions }) {
|
||||
try {
|
||||
const response = await fetch("https://api.openai.com/v1/responses", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: RESPONSE_MODEL,
|
||||
instructions,
|
||||
input: prompt,
|
||||
}),
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
body?.error?.message ?? `Responses API returned ${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return { message: extractResponseText(body) };
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
message:
|
||||
`Responses API request failed: ${errorMessage}\n\n` +
|
||||
"This browser prototype sends the stored key directly from the page. " +
|
||||
"If the request is blocked, use a local dev proxy or an ephemeral-token flow.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function extractResponseText(response) {
|
||||
if (typeof response.output_text === "string" && response.output_text) {
|
||||
return response.output_text;
|
||||
}
|
||||
|
||||
const chunks = [];
|
||||
for (const item of response.output ?? []) {
|
||||
for (const content of item.content ?? []) {
|
||||
if (typeof content.text === "string") {
|
||||
chunks.push(content.text);
|
||||
} else if (typeof content.output_text === "string") {
|
||||
chunks.push(content.output_text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return chunks.join("\n") || JSON.stringify(response, null, 2);
|
||||
}
|
||||
|
||||
function readStoredApiKey() {
|
||||
try {
|
||||
return localStorage.getItem(API_KEY_STORAGE_KEY) ?? "";
|
||||
|
||||
@@ -2,24 +2,29 @@
|
||||
//!
|
||||
//! This crate intentionally starts outside `codex-core`: the first milestone is
|
||||
//! a working browser boundary that streams Codex-shaped turn events. Later
|
||||
//! iterations can replace the sampler callback with the real Codex
|
||||
//! iterations can replace the direct model request with the real Codex
|
||||
//! `RegularTask` / `run_turn` path as host services become injectable.
|
||||
|
||||
use js_sys::Function;
|
||||
use js_sys::Promise;
|
||||
use js_sys::Reflect;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use web_sys::Headers;
|
||||
use web_sys::RequestInit;
|
||||
use web_sys::RequestMode;
|
||||
use web_sys::Response;
|
||||
|
||||
const DEFAULT_INSTRUCTIONS: &str = "You are Codex running in a browser WASM prototype.";
|
||||
const RESPONSES_API_URL: &str = "https://api.openai.com/v1/responses";
|
||||
const RESPONSES_MODEL: &str = "gpt-5.1";
|
||||
|
||||
/// Browser entrypoint for the prototype harness.
|
||||
#[wasm_bindgen]
|
||||
pub struct BrowserCodex {
|
||||
sampler: Option<Function>,
|
||||
api_key: String,
|
||||
next_turn_id: u32,
|
||||
}
|
||||
|
||||
@@ -27,18 +32,23 @@ pub struct BrowserCodex {
|
||||
impl BrowserCodex {
|
||||
/// Creates a new browser harness.
|
||||
///
|
||||
/// `sampler` may be a JavaScript function that accepts a request object and
|
||||
/// returns either a string, `{ message: string }`, or a Promise for either.
|
||||
/// When omitted, the harness uses a deterministic local demo response.
|
||||
/// `api_key` may be empty. When it is empty, the harness uses a
|
||||
/// deterministic local demo response. When it is present, the harness makes
|
||||
/// a browser `fetch` call to the Responses API from Rust/WASM.
|
||||
#[wasm_bindgen(constructor)]
|
||||
#[must_use]
|
||||
pub fn new(sampler: JsValue) -> Self {
|
||||
pub fn new(api_key: String) -> Self {
|
||||
Self {
|
||||
sampler: sampler.dyn_into::<Function>().ok(),
|
||||
api_key,
|
||||
next_turn_id: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the API key used by future turns.
|
||||
pub fn set_api_key(&mut self, api_key: String) {
|
||||
self.api_key = api_key;
|
||||
}
|
||||
|
||||
/// Submits one browser turn and calls `on_event` for every emitted event.
|
||||
///
|
||||
/// The method resolves after `turn_complete` has been emitted. This mirrors
|
||||
@@ -68,8 +78,7 @@ impl BrowserCodex {
|
||||
},
|
||||
)?;
|
||||
|
||||
let request = SamplingRequest::new(turn_id.clone(), prompt.clone());
|
||||
let agent_message = self.sample(request).await?;
|
||||
let agent_message = self.sample(&prompt).await?;
|
||||
|
||||
emit_event(
|
||||
&on_event,
|
||||
@@ -98,37 +107,21 @@ impl BrowserCodex {
|
||||
}
|
||||
|
||||
impl BrowserCodex {
|
||||
async fn sample(&self, request: SamplingRequest) -> Result<String, JsValue> {
|
||||
let Some(sampler) = &self.sampler else {
|
||||
return Ok(default_demo_response(&request.prompt));
|
||||
};
|
||||
async fn sample(&self, prompt: &str) -> Result<String, JsValue> {
|
||||
if self.api_key.trim().is_empty() {
|
||||
return Ok(default_demo_response(prompt));
|
||||
}
|
||||
|
||||
let request_json = serde_json::to_string(&request).map_err(js_error)?;
|
||||
let request_value = js_sys::JSON::parse(&request_json)?;
|
||||
let sampled = sampler.call1(&JsValue::NULL, &request_value)?;
|
||||
let resolved = JsFuture::from(Promise::resolve(&sampled)).await?;
|
||||
extract_message(resolved)
|
||||
sample_with_responses_api(self.api_key.trim(), prompt).await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SamplingRequest {
|
||||
turn_id: String,
|
||||
prompt: String,
|
||||
struct ResponsesRequest<'a> {
|
||||
model: &'static str,
|
||||
instructions: &'static str,
|
||||
tools: Vec<String>,
|
||||
}
|
||||
|
||||
impl SamplingRequest {
|
||||
fn new(turn_id: String, prompt: String) -> Self {
|
||||
Self {
|
||||
turn_id,
|
||||
prompt,
|
||||
instructions: DEFAULT_INSTRUCTIONS,
|
||||
tools: Vec::new(),
|
||||
}
|
||||
}
|
||||
input: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -158,11 +151,102 @@ enum HarnessEvent<'a> {
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SamplerResponse {
|
||||
struct ResponsesBody {
|
||||
output_text: Option<String>,
|
||||
output: Option<Vec<ResponsesOutputItem>>,
|
||||
error: Option<ResponsesError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResponsesError {
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResponsesOutputItem {
|
||||
content: Option<Vec<ResponsesContentItem>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResponsesContentItem {
|
||||
text: Option<String>,
|
||||
output_text: Option<String>,
|
||||
}
|
||||
|
||||
async fn sample_with_responses_api(api_key: &str, prompt: &str) -> Result<String, JsValue> {
|
||||
let body = ResponsesRequest {
|
||||
model: RESPONSES_MODEL,
|
||||
instructions: DEFAULT_INSTRUCTIONS,
|
||||
input: prompt,
|
||||
};
|
||||
let body = serde_json::to_string(&body).map_err(js_error)?;
|
||||
|
||||
let headers = Headers::new()?;
|
||||
headers.append("Authorization", &format!("Bearer {api_key}"))?;
|
||||
headers.append("Content-Type", "application/json")?;
|
||||
|
||||
let request = RequestInit::new();
|
||||
request.set_method("POST");
|
||||
request.set_mode(RequestMode::Cors);
|
||||
request.set_headers(&headers);
|
||||
request.set_body(&JsValue::from_str(&body));
|
||||
|
||||
let window = web_sys::window().ok_or_else(|| js_error("window is unavailable"))?;
|
||||
let response_value =
|
||||
JsFuture::from(window.fetch_with_str_and_init(RESPONSES_API_URL, &request))
|
||||
.await
|
||||
.map_err(response_error)?;
|
||||
let response: Response = response_value.dyn_into()?;
|
||||
let status = response.status();
|
||||
let ok = response.ok();
|
||||
let json = JsFuture::from(response.json()?)
|
||||
.await
|
||||
.map_err(response_error)?;
|
||||
let response_body = parse_response_body(json)?;
|
||||
|
||||
if !ok {
|
||||
let message = response_body
|
||||
.error
|
||||
.and_then(|err| err.message)
|
||||
.unwrap_or_else(|| format!("Responses API returned {status}"));
|
||||
return Err(js_error(message));
|
||||
}
|
||||
|
||||
Ok(extract_response_text(response_body))
|
||||
}
|
||||
|
||||
fn parse_response_body(value: JsValue) -> Result<ResponsesBody, JsValue> {
|
||||
let json = js_sys::JSON::stringify(&value)?
|
||||
.as_string()
|
||||
.ok_or_else(|| js_error("Responses API returned non-JSON output"))?;
|
||||
serde_json::from_str(&json).map_err(js_error)
|
||||
}
|
||||
|
||||
fn extract_response_text(response: ResponsesBody) -> String {
|
||||
if let Some(output_text) = response.output_text
|
||||
&& !output_text.is_empty()
|
||||
{
|
||||
return output_text;
|
||||
}
|
||||
|
||||
let mut chunks = Vec::new();
|
||||
for item in response.output.unwrap_or_default() {
|
||||
for content in item.content.unwrap_or_default() {
|
||||
if let Some(text) = content.text {
|
||||
chunks.push(text);
|
||||
} else if let Some(output_text) = content.output_text {
|
||||
chunks.push(output_text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if chunks.is_empty() {
|
||||
"Responses API returned no output text.".to_string()
|
||||
} else {
|
||||
chunks.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_event(on_event: &Function, event: &HarnessEvent<'_>) -> Result<(), JsValue> {
|
||||
let json = serde_json::to_string(event).map_err(js_error)?;
|
||||
let value = js_sys::JSON::parse(&json)?;
|
||||
@@ -170,41 +254,32 @@ fn emit_event(on_event: &Function, event: &HarnessEvent<'_>) -> Result<(), JsVal
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_message(value: JsValue) -> Result<String, JsValue> {
|
||||
if let Some(message) = value.as_string() {
|
||||
return Ok(message);
|
||||
}
|
||||
|
||||
if value.is_object() {
|
||||
if let Some(message) = Reflect::get(&value, &JsValue::from_str("message"))?.as_string() {
|
||||
return Ok(message);
|
||||
}
|
||||
|
||||
let json = js_sys::JSON::stringify(&value)?;
|
||||
if let Some(json) = json.as_string()
|
||||
&& let Ok(response) = serde_json::from_str::<SamplerResponse>(&json)
|
||||
&& let Some(message) = response.message
|
||||
{
|
||||
return Ok(message);
|
||||
}
|
||||
}
|
||||
|
||||
Err(js_error(
|
||||
"sampler must return a string or an object with a string message field",
|
||||
))
|
||||
}
|
||||
|
||||
fn default_demo_response(prompt: &str) -> String {
|
||||
if prompt.to_ascii_lowercase().contains("hello world") {
|
||||
"Here is a minimal hello world example:\n\n```js\nconsole.log(\"hello world\");\n```"
|
||||
.to_string()
|
||||
} else {
|
||||
format!(
|
||||
"Browser Codex prototype received the prompt, but no model sampler was configured: {prompt}"
|
||||
"Browser Codex prototype received the prompt, but no API key was configured: {prompt}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn response_error(error: JsValue) -> JsValue {
|
||||
if let Some(message) = error.as_string() {
|
||||
return js_error(message);
|
||||
}
|
||||
|
||||
if error.is_object()
|
||||
&& let Ok(value) = Reflect::get(&error, &JsValue::from_str("message"))
|
||||
&& let Some(message) = value.as_string()
|
||||
{
|
||||
return js_error(message);
|
||||
}
|
||||
|
||||
js_error("Responses API request failed")
|
||||
}
|
||||
|
||||
fn js_error(message: impl ToString) -> JsValue {
|
||||
JsValue::from_str(&message.to_string())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user