blob: 49c0d4d0725801a014f3d2f0242307de6c76485c [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use base64::Engine;
use futures_util::future::BoxFuture;
use maka_agent_ports::model;
use maka_agent_ports::model::Context;
use maka_agent_ports::model::Error;
use maka_agent_ports::model::Lifetime;
use maka_agent_ports::model::Message;
use maka_agent_ports::model::ProviderAdapter;
use maka_agent_ports::model::Request;
use maka_agent_ports::model::Session;
use serde_json::{Value, json};
use std::{collections::BTreeMap, sync::Arc};
use tokio_util::sync::CancellationToken;
mod inventory;
mod login;
mod provider;
pub use inventory::decode_model_inventory;
use provider::configuration;
pub const ID: &str = "maka.codex";
pub const ADAPTER: &str = "codex-responses";
/// ChatGPT's subscription profile composes the ordinary Responses implementation.
#[derive(Default)]
pub struct Codex(maka_responses::Adapter);
impl ProviderAdapter for Codex {
fn supports_item_acceptance(&self) -> bool {
self.0.supports_item_acceptance()
}
fn enforces_idle_timeout(&self) -> bool {
self.0.enforces_idle_timeout()
}
fn open(
&self,
lifetime: Lifetime,
cancellation: CancellationToken,
) -> BoxFuture<'_, Result<Arc<dyn Session>, Error>> {
Box::pin(async move {
let inner = self.0.open(lifetime, cancellation).await?;
Ok(Arc::new(Subscription(inner)) as Arc<dyn Session>)
})
}
}
struct Subscription(Arc<dyn Session>);
impl Session for Subscription {
fn try_switch_fallback_transport(&self) -> bool {
self.0.try_switch_fallback_transport()
}
fn stream(
&self,
mut request: Request,
context: Context,
) -> BoxFuture<'static, Result<(), Error>> {
if let Err(error) = prepare(&mut request) {
return Box::pin(async { Err(error) });
}
self.0.stream(request, context)
}
fn needs_confirmation(&self) -> bool {
self.0.needs_confirmation()
}
fn confirm(
&self,
prompt: &[Message],
settled_tool_call_ids: &[&str],
response_id: Option<&str>,
) -> bool {
self.0.confirm(prompt, settled_tool_call_ids, response_id)
}
}
/// Invocation-only credentials, never provider metadata or a composition source.
pub fn request_headers(
access_token: &str,
session_id: &str,
) -> Result<BTreeMap<String, String>, Error> {
if session_id.is_empty() || session_id.chars().any(char::is_control) {
return Err(Error::Adapter(
"invalid subscription session identity".into(),
));
}
let mut headers = BTreeMap::from([
("authorization".into(), format!("Bearer {access_token}")),
("openai-beta".into(), "responses=experimental".into()),
("originator".into(), "codex_cli_rs".into()),
("user-agent".into(), "codex_cli_rs/0.0.0 (Maka)".into()),
("session_id".into(), session_id.into()),
("x-client-request-id".into(), session_id.into()),
]);
if let Some(account) = account_id(access_token) {
headers.insert("chatgpt-account-id".into(), account);
}
Ok(headers)
}
fn prepare(request: &mut Request) -> Result<(), Error> {
if !matches!(request.provider.kind, model::ProviderKind::OpenaiResponses) {
return Err(Error::Adapter(
"subscription requires the Responses protocol".into(),
));
}
// The subscription endpoint rejects max_output_tokens. Host-side reply
// budgets still reserve context; only this provider's wire request omits it.
request.max_output_tokens = None;
// Resolve against full canonical input before the Responses lane removes a
// confirmed prefix. A tool-only continuation must retain its instructions.
if request.provider_options.is_null() {
request.provider_options = json!({});
}
let options = request
.provider_options
.as_object_mut()
.ok_or_else(|| Error::Adapter("provider options must be an object".into()))?;
let options = options.entry("openai").or_insert_with(|| json!({}));
let options = options
.as_object_mut()
.ok_or_else(|| Error::Adapter("OpenAI options must be an object".into()))?;
if !options
.get("instructions")
.and_then(Value::as_str)
.is_some_and(|v| !v.trim().is_empty())
{
let instructions = request
.prompt
.iter()
.find_map(|message| match message {
model::Message::System { content, .. } if !content.trim().is_empty() => {
Some(content.clone())
}
_ => None,
})
.unwrap_or_default();
options.insert("instructions".into(), Value::String(instructions));
}
options.insert("store".into(), Value::Bool(false));
if !options.get("textVerbosity").is_some_and(Value::is_string) {
options.insert("textVerbosity".into(), json!("medium"));
}
Ok(())
}
fn account_id(token: &str) -> Option<String> {
let mut parts = token.split('.');
parts.next()?;
let payload = parts.next()?;
parts.next()?;
if parts.next().is_some() {
return None;
}
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload.trim_end_matches('='))
.ok()?;
let claims: Value = serde_json::from_slice(&bytes).ok()?;
for value in [
&claims["chatgpt_account_id"],
&claims["https://api.openai.com/auth"]["chatgpt_account_id"],
] {
if let Some(id) = value.as_str().filter(|v| !v.is_empty()) {
return Some(id.into());
}
}
claims["organizations"].as_array()?.iter().find_map(|v| {
v["id"]
.as_str()
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_owned)
})
}