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
17 changes: 4 additions & 13 deletions crates/puffer-core/runtime/openai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ use super::{
APP_VERSION, OPENAI_CODEX_COMPAT_VERSION,
};
mod adapters;
mod codex_prompt;
mod completions_session;
pub(crate) mod conversation;
mod legacy_streaming;
mod prompt_context;
mod responses_session;
mod support;
mod websocket;
Expand Down Expand Up @@ -503,31 +505,20 @@ pub(super) fn parse_openai_text(response: &Value) -> Result<String> {
Ok(parts.join("\n"))
}

pub(super) fn openai_request_instructions(
state: &mut AppState,
resources: &LoadedResources,
system_prompt: Option<&str>,
) -> Result<String> {
pub(super) fn openai_request_instructions(system_prompt: Option<&str>) -> String {
let mut sections = Vec::new();
if let Some(system_prompt) = system_prompt
.map(str::trim)
.filter(|prompt| !prompt.is_empty())
{
sections.push(system_prompt.to_string());
}
if let Some(plan_mode_context) =
crate::plan_mode::take_plan_mode_context_message(state, resources)?
.map(|message| message.trim().to_string())
.filter(|message| !message.is_empty())
{
sections.push(plan_mode_context);
}
// Dynamic context (date, git status, CLAUDE.md) is now injected as a
// context user message in the `input` array, not here. This keeps
// `instructions` static and cacheable (matching Codex's design where
// `instructions` = pure developer instructions, and contextual data
// lives in `input` items).
Ok(sections.join("\n\n"))
sections.join("\n\n")
}

/// Builds the dynamic context message injected into the `input` array.
Expand Down
87 changes: 87 additions & 0 deletions crates/puffer-core/runtime/openai/codex_prompt.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
use anyhow::Result;
use puffer_provider_registry::ModelDescriptor;
use puffer_resources::LoadedResources;
use std::collections::BTreeSet;

use super::conversation::{ContentPart, ConversationItem};
use crate::permissions::RuntimePermissionContext;
use crate::runtime::system_prompt::{
load_openai_project_memory_context, render_openai_codex_contextual_user_prompt,
render_runtime_prompt_resource,
};
use crate::AppState;

const OPENAI_CODEX_BASE_PROMPT_ID: &str = "openai-codex-base";
const OPENAI_CODEX_DEVELOPER_PROMPT_ID: &str = "openai-codex-developer";

pub(super) struct CodexPromptLayers {
pub instructions: String,
pub developer_text: String,
pub contextual_user_text: Option<String>,
}

pub(super) fn build_codex_prompt_layers(
state: &AppState,
resources: &LoadedResources,
model: &ModelDescriptor,
enabled_tools: &BTreeSet<String>,
permission_context: &RuntimePermissionContext,
) -> Result<CodexPromptLayers> {
let instructions = render_runtime_prompt_resource(
state,
resources,
&model.id,
enabled_tools,
OPENAI_CODEX_BASE_PROMPT_ID,
false,
)?;
let developer_text = render_runtime_prompt_resource(
state,
resources,
&model.id,
enabled_tools,
OPENAI_CODEX_DEVELOPER_PROMPT_ID,
false,
)?;
let mut contextual_user_text = render_openai_codex_contextual_user_prompt(
state,
resources,
&model.id,
enabled_tools,
permission_context,
)?;
if let Some(project_memory) = load_openai_project_memory_context(&state.cwd) {
append_section(&mut contextual_user_text, &project_memory);
}

Ok(CodexPromptLayers {
instructions,
developer_text,
contextual_user_text: non_empty(contextual_user_text),
})
}

pub(super) fn developer_message(content: impl Into<String>) -> ConversationItem {
ConversationItem::Message {
role: "developer".to_string(),
content: vec![ContentPart::Text {
text: content.into(),
}],
}
}

pub(super) fn append_section(target: &mut String, section: &str) {
let section = section.trim();
if section.is_empty() {
return;
}
if !target.trim().is_empty() {
target.push_str("\n\n");
}
target.push_str(section);
}

fn non_empty(text: String) -> Option<String> {
let trimmed = text.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
93 changes: 66 additions & 27 deletions crates/puffer-core/runtime/openai/legacy_streaming.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
use super::prompt_context::{
apply_managed_system_prompt_to_bundle, apply_plan_mode_context_to_bundle,
apply_request_wire_compat, apply_text_verbosity_compat, build_openai_responses_prompt_bundle,
insert_leading_input_items, supports_client_metadata, supports_parallel_tool_calls,
};
use super::support::{
apply_previous_response_id, build_codex_openai_request_body, is_openai_structured_output_error,
is_retryable_openai_stream_error, openai_model_supports_reasoning, openai_responses_path,
Expand All @@ -6,16 +11,15 @@ use super::support::{
OPENAI_STRUCTURED_OUTPUT_FAMILY,
};
use super::{
build_context_reminder_message, execute_openai_tool_calls, openai_request_instructions,
parse_openai_text, parse_openai_text_fallback, resolve_openai_execution_config,
build_context_reminder_message, execute_openai_tool_calls, parse_openai_text,
parse_openai_text_fallback, resolve_openai_execution_config,
send_openai_request_with_refresh_streaming,
};
use crate::permissions::{load_runtime_permission_context_with_inputs, RuntimePermissionInputs};
use crate::runtime;
use crate::runtime::structured_output_support::{
openai_responses_text_config, openai_tool_definitions_for_request,
};
use crate::runtime::system_prompt::render_runtime_system_prompt;
use crate::runtime::{run_turn_hooks, RetryAttemptKind, TurnStreamEvent};
use crate::AppState;
use anyhow::Result;
Expand Down Expand Up @@ -94,10 +98,9 @@ where
F: FnMut(TurnStreamEvent),
{
use super::conversation::{
append_managed_system_prompt_1_to_instructions, append_reasoning_items,
append_tool_results, compact_conversation, inject_post_compact_context,
items_to_responses_input, managed_system_prompt_1_from_env, transcript_to_items,
ConversationItem,
append_reasoning_items, append_tool_results, compact_conversation,
inject_post_compact_context, items_to_responses_input, managed_system_prompt_1_from_env,
transcript_to_items, ConversationItem,
};

let structured_output = options.structured_output;
Expand All @@ -112,7 +115,7 @@ where
request_tool_filter: options.tool_filter.cloned(),
},
)?;
let text = openai_responses_text_config(structured_output, use_native);
let mut text = openai_responses_text_config(structured_output, use_native);
let tools = {
let mut t = openai_tool_definitions_for_request(
&registry,
Expand All @@ -130,31 +133,56 @@ where
}
t
};
let mut instructions = if options.lightweight_context {
"Reply directly and concisely.".to_string()
} else {
let system_prompt = render_runtime_system_prompt(
state,
resources,
&model_id,
&tools
.iter()
.map(|tool| tool.name.clone())
.collect::<std::collections::BTreeSet<_>>(),
)?;
openai_request_instructions(state, resources, Some(&system_prompt))?
};
let enabled_tool_names = tools
.iter()
.map(|tool| tool.name.clone())
.collect::<std::collections::BTreeSet<_>>();
let model = provider
.models
.iter()
.find(|model| model.id == model_id)
.cloned()
.unwrap_or_else(|| puffer_provider_registry::ModelDescriptor {
id: model_id.clone(),
display_name: model_id.clone(),
provider: provider.id.clone(),
api: provider.default_api.clone(),
context_window: 0,
max_output_tokens: 0,
supports_reasoning: false,
compat: None,
input: Vec::new(),
cost: None,
});
let mut prompt_bundle = build_openai_responses_prompt_bundle(
state,
resources,
provider,
&model,
&enabled_tool_names,
&permission_context,
&options,
)?;
text = apply_text_verbosity_compat(text, &model);
// Unified: all internal logic on Vec<ConversationItem>.
let mut items = transcript_to_items(state, input);
let managed_system_prompt_1 = if options.lightweight_context {
None
} else {
managed_system_prompt_1_from_env()
};
append_managed_system_prompt_1_to_instructions(
&mut instructions,
apply_managed_system_prompt_to_bundle(
&mut prompt_bundle,
&model,
managed_system_prompt_1.as_deref(),
);
let plan_mode_context = if options.lightweight_context {
None
} else {
crate::plan_mode::take_plan_mode_context_message(state, resources)?
};
apply_plan_mode_context_to_bundle(&mut prompt_bundle, &model, plan_mode_context.as_deref());
let instructions = super::openai_request_instructions(Some(&prompt_bundle.instructions));
let mut reflection = options
.reflection
.map(|config| runtime::reflection::ReflectionTracker::new(input, config));
Expand All @@ -163,6 +191,7 @@ where
// Inject dynamic context as a user message at the start of the input
// array (matching Codex/CC pattern).
if !options.lightweight_context {
insert_leading_input_items(&mut items, &prompt_bundle.leading_input_items);
let context_reminder = build_context_reminder_message(state);
super::conversation::insert_context_reminder_preserving_legacy_leading_system(
&mut items,
Expand All @@ -172,9 +201,13 @@ where

let mut invocations = Vec::new();
let supports_reasoning = openai_model_supports_reasoning(provider, &model_id);
let model = provider.models.iter().find(|m| m.id == model_id);
let supports_response_threading =
openai_supports_response_threading(provider, &execution.request_config.base_url, model);
let supports_response_threading = openai_supports_response_threading(
provider,
&execution.request_config.base_url,
Some(&model),
);
let supports_client_metadata = supports_client_metadata(&model);
let supports_parallel_tool_calls = supports_parallel_tool_calls(&model);
let mut previous_response_id: Option<String> = None;
// Index where "continuation" items start — used for previous_response_id optimization.
// When previous_response_id is set, only items[start..] are sent as wire input.
Expand Down Expand Up @@ -233,6 +266,12 @@ where
text.clone(),
true,
);
apply_request_wire_compat(
&mut body,
state,
supports_client_metadata,
supports_parallel_tool_calls,
);
apply_previous_response_id(&mut body, prev_resp_id.as_deref());
build_json_post_request(
request_config,
Expand Down
Loading