Skip to content

Commit 4d71580

Browse files
committed
feat: Z.AI rate limit handling and GitHub Copilot PAT support
Problem: Multiple issues with rate limit detection and API authentication. Solution: Comprehensive fixes for Z.AI rate limit handling, improved header capture for curl streaming, and static PAT support for GitHub Copilot. Rate Limit Handling (Z.AI): - Handle Z.AI rate limit code 1310 (weekly/monthly limit exhaustion) - Parse x-ratelimit-user-retry-after header for accurate reset times - Improve human-readable time formatting (hours/days instead of seconds) - Add debug logging to capture all rate limit headers for diagnosis - Fix header parsing to properly extract X-RateLimit-User-Retry-After Curl Streaming Fixes: - Buffer chunks during streaming and deliver after headers are parsed - Create response object lazily with correct headers after header file is read - Add debug logging for header parsing to diagnose header capture issues - Ensure callback receives correct response object with parsed headers GitHub Copilot Static PAT Support: - Allow static API key fallback when GitHub authentication is unavailable - Support fine-grained PATs (ghu_ prefix) with individual endpoint directly - Set using_exchanged_token flag when using PAT for proper header support - Skip GitHub auth checks when static API key is configured - Fix billing multiplier check for when model prefix is used Config Storage Fix: - Prevent flat api_base from overriding per-provider storage - Check for provider-specific api_keys before falling back Documentation: - Standardize MiniMax model recommendation for all sub-agents - Add comprehensive system prompt and documentation review Bug Fixes: - Add missing log_info import in PromptManager - Remove duplicate empty elsif block in ResponseHandler error handling Testing: - Verified Z.AI rate limit codes 1308 and 1310 are handled correctly - Verified curl streaming properly captures and delivers rate limit headers - Verified GitHub Copilot works with both GitHub auth and static PATs - All unit tests pass
1 parent be80470 commit 4d71580

9 files changed

Lines changed: 154 additions & 47 deletions

File tree

AGENTS.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,17 @@ Terminal Output (with color/theme)
111111

112112
---
113113

114+
## Model Selection
115+
116+
**Use MiniMax for all sub-agents:**
117+
```
118+
agent_operations(operation: "spawn", task: "...", working_dir: "./CLIO", model: "minimax/minimax-m2.7")
119+
```
120+
121+
MiniMax-M2.7 via MiniMax is the recommended default for all standard tasks: investigation, QA, implementation, code review, refactoring, documentation.
122+
123+
---
124+
114125
## Code Style
115126

116127
**Perl Conventions:**

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
20260418.5
1+
20260419.1

lib/CLIO.pm

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,15 @@ use strict;
77
use warnings;
88
use utf8;
99

10-
our $VERSION = '20260418.5';
10+
our $VERSION = '20260419.1';
1111

1212
=head1 NAME
1313
1414
CLIO - Command Line Intelligence Orchestrator
1515
1616
=head1 VERSION
1717
18-
Version 20260418.5
18+
Version 20260419.1
1919
2020
=head1 DESCRIPTION
2121

lib/CLIO/Compat/HTTP.pm

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -403,9 +403,9 @@ sub _request_via_curl_streaming {
403403

404404
# Read and deliver chunks incrementally
405405
my $accumulated_content = '';
406-
my $resp_obj;
407406
my $read_buf;
408407
my $chunk_size = 4096; # Read in 4KB chunks for responsive streaming
408+
my @chunks; # Buffer chunks to deliver after headers are parsed
409409

410410
# sysread on the pipe will block until data arrives or be interrupted by signals
411411
# The ALRM signal handler (set by Chat.pm) fires every second, causing EINTR
@@ -423,23 +423,7 @@ sub _request_via_curl_streaming {
423423
last if $bytes == 0; # EOF
424424

425425
$accumulated_content .= $read_buf;
426-
427-
# Create response object lazily (we don't have headers yet from pipe mode)
428-
if (!$resp_obj) {
429-
$resp_obj = bless {
430-
success => 1, # Assume success, will verify later
431-
status => 200,
432-
reason => 'OK',
433-
content => '',
434-
headers => {},
435-
}, 'CLIO::Compat::HTTP::Response';
436-
}
437-
438-
# Deliver chunk to callback
439-
eval { $callback->($read_buf, $resp_obj, undef); };
440-
if ($@) {
441-
log_warning('HTTP::curl_streaming', "Callback error: $@");
442-
}
426+
push @chunks, $read_buf; # Buffer chunk for later delivery
443427
}
444428

445429
close($curl_fh);
@@ -466,9 +450,11 @@ sub _request_via_curl_streaming {
466450
my %resp_headers;
467451

468452
if (open(my $hfh, '<', $hdr_file)) {
453+
log_debug('HTTP::curl_streaming', "Parsing headers from $hdr_file");
469454
while (my $line = <$hfh>) {
470455
chomp $line;
471456
$line =~ s/\r$//;
457+
log_debug('HTTP::curl_streaming', "Header line: $line");
472458
if ($line =~ /^HTTP\/[\d.]+\s+(\d+)\s*(.*)$/) {
473459
$status = $1;
474460
$reason = $2 // '';
@@ -477,6 +463,9 @@ sub _request_via_curl_streaming {
477463
}
478464
}
479465
close $hfh;
466+
log_debug('HTTP::curl_streaming', "Parsed " . scalar(keys %resp_headers) . " headers: " . join(", ", keys %resp_headers));
467+
} else {
468+
log_debug('HTTP::curl_streaming', "Failed to open header file $hdr_file: $!");
480469
}
481470

482471
# If no HTTP status was parsed from headers, check curl exit code
@@ -514,13 +503,27 @@ sub _request_via_curl_streaming {
514503
log_debug('HTTP', "Streaming complete: status=$status, " . length($accumulated_content) . " bytes, exit_code=$exit_code");
515504
}
516505

517-
return {
506+
# Create response object with correct headers (parsed from header file)
507+
# and deliver buffered chunks to callback
508+
my $final_response = bless {
518509
success => ($status >= 200 && $status < 300),
519510
status => $status,
520511
reason => $reason,
521512
headers => \%resp_headers,
522513
content => $accumulated_content,
523-
};
514+
}, 'CLIO::Compat::HTTP::Response';
515+
516+
# Deliver buffered chunks to callback with correct response object
517+
if ($callback) {
518+
for my $chunk (@chunks) {
519+
eval { $callback->($chunk, $final_response, undef); };
520+
if ($@) {
521+
log_warning('HTTP::curl_streaming', "Callback error: $@");
522+
}
523+
}
524+
}
525+
526+
return $final_response;
524527
}
525528

526529
=head2 request

lib/CLIO/Core/API/ResponseHandler.pm

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,24 @@ sub handle_error_response {
344344
$reset_timestamp = $passed_headers->header('X-RateLimit-Reset');
345345
}
346346
}
347+
348+
# Debug: log all rate limit headers for weekly/monthly limit diagnosis
349+
if ($detected_rate_limit_code && $detected_rate_limit_code =~ /user_weekly_rate_limited|user_monthly_rate_limited/i) {
350+
my $header_debug = 'Rate limit headers debug: ';
351+
if (ref($passed_headers) eq 'HASH') {
352+
$header_debug .= "hash{retry-after}=$passed_headers->{'retry-after'}, ";
353+
$header_debug .= "hash{x-ratelimit-user-retry-after}=$passed_headers->{'x-ratelimit-user-retry-after'}, ";
354+
$header_debug .= "hash{x-ratelimit-reset}=$passed_headers->{'x-ratelimit-reset'}";
355+
} elsif (ref($passed_headers) && $passed_headers->can('header')) {
356+
$header_debug .= "object{" . ref($passed_headers) . "}";
357+
$header_debug .= " Retry-After=" . (defined($passed_headers->header('Retry-After')) ? "'" . $passed_headers->header('Retry-After') . "'" : 'undef');
358+
$header_debug .= " X-RateLimit-User-Retry-After=" . (defined($passed_headers->header('X-RateLimit-User-Retry-After')) ? "'" . $passed_headers->header('X-RateLimit-User-Retry-After') . "'" : 'undef');
359+
$header_debug .= " X-RateLimit-Reset=" . (defined($passed_headers->header('X-RateLimit-Reset')) ? "'" . $passed_headers->header('X-RateLimit-Reset') . "'" : 'undef');
360+
} else {
361+
$header_debug .= "passed_headers is " . (defined($passed_headers) ? "'$passed_headers' (" . ref($passed_headers) . ")" : 'undef');
362+
}
363+
log_info('ResponseHandler', $header_debug);
364+
}
347365

348366
if ($error =~ /retry in ([\d.]+)\s*s(?:econds?)?/i) {
349367
$retry_after = int($1) + 1;
@@ -390,17 +408,34 @@ sub handle_error_response {
390408
log_debug('ResponseHandler', "Rate limit check: detected_code=$detected_rate_limit_code, retry_after=$retry_after, reset_ts=$reset_timestamp");
391409
log_debug('ResponseHandler', "Rate limit error_obj: " . encode_json($error_obj)) if $error_obj;
392410
if ($detected_rate_limit_code && $detected_rate_limit_code =~ /user_weekly_rate_limited|user_monthly_rate_limited/i) {
393-
# For weekly/monthly limits, use reset_timestamp if available to get accurate time
411+
# For weekly/monthly limits, we need the actual reset time from x-ratelimit-user-retry-after
412+
# The short retry-after header (e.g., "4") is misleading for weekly limits
394413
my $actual_retry_after;
395-
if ($reset_timestamp && $reset_timestamp =~ /^\d+$/ && $reset_timestamp > time()) {
414+
my $long_retry_header;
415+
416+
# Try to get the long-duration retry header specifically
417+
if (ref($passed_headers) eq 'HASH') {
418+
$long_retry_header = $passed_headers->{'x-ratelimit-user-retry-after'};
419+
} elsif ($passed_headers && $passed_headers->can('header')) {
420+
$long_retry_header = $passed_headers->header('X-RateLimit-User-Retry-After');
421+
}
422+
423+
if ($long_retry_header && $long_retry_header =~ /^\d+$/) {
424+
# x-ratelimit-user-retry-after is already in seconds
425+
$actual_retry_after = int($long_retry_header);
426+
log_info('ResponseHandler', "Using x-ratelimit-user-retry-after: ${actual_retry_after}s");
427+
} elsif ($reset_timestamp && $reset_timestamp =~ /^\d+$/ && $reset_timestamp > time()) {
396428
$actual_retry_after = int($reset_timestamp - time());
397429
} elsif (defined $self->{_rate_limit_reset_in} && $self->{_rate_limit_reset_in} > 0) {
398430
# Use cached reset time from previous successful responses
399431
$actual_retry_after = $self->{_rate_limit_reset_in};
400432
log_info('ResponseHandler', "Using cached rate limit reset time: ${actual_retry_after}s");
401433
} else {
402434
# API didn't provide accurate reset time - don't show misleading value
403-
log_info('ResponseHandler', "No reset time available: _rate_limit_reset_in=" .
435+
log_info('ResponseHandler', "No reset time available: long_retry_header=" .
436+
(defined $long_retry_header ? $long_retry_header : 'undef') .
437+
", retry_after_header=" . (defined $retry_after_header ? $retry_after_header : 'undef') .
438+
", reset_timestamp=$reset_timestamp, _rate_limit_reset_in=" .
404439
(defined $self->{_rate_limit_reset_in} ? $self->{_rate_limit_reset_in} : 'undef'));
405440
$actual_retry_after = undef;
406441
}
@@ -412,10 +447,13 @@ sub handle_error_response {
412447
my $expiration_str = '';
413448
if (defined($actual_retry_after) && $actual_retry_after > 0) {
414449
my $days = int($actual_retry_after / 86400);
450+
my $hours = int(($actual_retry_after % 86400) / 3600);
415451
if ($days > 0) {
416-
$expiration_str = sprintf(" This limit expires in %ds (%d days).", $actual_retry_after, $days);
452+
$expiration_str = sprintf(" This limit expires in ~%d hours (%d days).", $actual_retry_after / 3600, $days);
453+
} elsif ($hours > 0) {
454+
$expiration_str = sprintf(" This limit expires in ~%d hours.", $hours);
417455
} else {
418-
$expiration_str = sprintf(" This limit expires in %ds.", $actual_retry_after);
456+
$expiration_str = sprintf(" This limit expires in ~%d minutes.", int($actual_retry_after / 60));
419457
}
420458
}
421459

@@ -451,7 +489,7 @@ sub handle_error_response {
451489
}
452490

453491
log_info('ResponseHandler', "Weekly/monthly rate limit detected: $detected_rate_limit_code" .
454-
(defined($actual_retry_after) ? ", expires in ${actual_retry_after}s" : " (reset time unknown)"));
492+
(defined($actual_retry_after) ? sprintf(", expires in %d seconds (~%.1f hours)", $actual_retry_after, $actual_retry_after / 3600) : " (reset time unknown)"));
455493

456494
# Build result directly for weekly/monthly limits (skip else/elsif chains)
457495
my $weekly_result = { success => 0, error => $error, _error => $error };
@@ -463,9 +501,10 @@ sub handle_error_response {
463501
log_debug('ResponseHandler', "Final error being returned: $error");
464502
return $weekly_result;
465503
}
466-
# Handle Z.AI usage limit (code 1308) - non-retryable, resets at specific time
504+
# Handle Z.AI usage limit (codes 1308 and 1310) - non-retryable, resets at specific time
467505
# Error message format: "Usage limit reached for 5 hour. Your limit will reset at 2026-04-17 07:03:43"
468-
elsif ($detected_rate_limit_code && $detected_rate_limit_code == 1308) {
506+
# Code 1310: "Weekly/Monthly Limit Exhausted. Your limit will reset at 2026-04-24 02:02:21"
507+
elsif ($detected_rate_limit_code && ($detected_rate_limit_code == 1308 || $detected_rate_limit_code == 1310)) {
469508
my $actual_retry_after;
470509
my $reset_str;
471510

@@ -556,8 +595,8 @@ sub handle_error_response {
556595
}
557596
}
558597

559-
log_info('ResponseHandler', "Z.AI usage limit detected (code=1308)" .
560-
(defined($actual_retry_after) ? ", expires in ${actual_retry_after}s" : " (reset time unknown)"));
598+
log_info('ResponseHandler', "Z.AI usage limit detected (code=$detected_rate_limit_code)" .
599+
(defined($actual_retry_after) ? sprintf(", expires in %d seconds (~%.1f hours)", $actual_retry_after, $actual_retry_after / 3600) : " (reset time unknown)"));
561600

562601
my $zai_result = { success => 0, error => $error, _error => $error };
563602
$zai_result->{retryable} = 0;
@@ -594,7 +633,6 @@ sub handle_error_response {
594633
$zai_rl_type, $detected_rate_limit_code, $retry_after);
595634
$error = $retry_info;
596635
log_info('ResponseHandler', "Z.AI $zai_rl_type limit (code=$detected_rate_limit_code), retry_after=${retry_after}s");
597-
} elsif ($user_message) {
598636
} elsif ($user_message) {
599637
$retry_info = sprintf("%s Retrying in %d seconds.", $user_message, $retry_after);
600638
$error = $retry_info;

lib/CLIO/Core/APIManager.pm

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -511,16 +511,18 @@ sub _get_api_key {
511511
return $github_token;
512512
}
513513

514-
# GitHub Copilot provider requires GitHub authentication
515-
log_warning('APIManager', "GitHub Copilot not authenticated");
516-
return '';
514+
# GitHub Copilot not authenticated via GitHub - will fall through to check static key
515+
log_info('APIManager', "GitHub Copilot not authenticated via GitHub, checking for static key");
517516
}
518517

519-
# Priority 2: Config api_key (for non-GitHub Copilot providers)
518+
# Priority 2: Config api_key (fallback for GitHub Copilot or primary for other providers)
520519
if ($self->{config} && $self->{config}->can('get')) {
521520
my $key = $self->{config}->get('api_key');
522521
if ($key && length($key) > 0) {
523522
log_debug('APIManager', "Using API key from Config");
523+
# Set using_exchanged_token so Editor-Version header is sent
524+
# This is needed for github_copilot to recognize the PAT properly
525+
$self->{using_exchanged_token} = 1 if $is_copilot_provider;
524526
return $key;
525527
}
526528
}
@@ -3233,6 +3235,33 @@ sub _handle_streaming_http_error {
32333235
# Use streaming headers if available (passed from _finalize_streaming_response)
32343236
my $headers = $s->{streaming_headers} || $resp->headers;
32353237

3238+
# Debug: log ALL headers from the response to see what's actually available
3239+
if ($headers && ref($headers) && $headers->can('header')) {
3240+
my @header_names = $headers->header_field_names();
3241+
if (@header_names) {
3242+
my $all_headers_str = join(", ", map { "$_=" . (defined($headers->header($_)) ? "'" . $headers->header($_) . "'" : 'undef') } @header_names);
3243+
log_debug('APIManager', "All response headers (${\scalar(@header_names)}): $all_headers_str");
3244+
} else {
3245+
log_info('APIManager', "Headers object exists but has NO fields - headers hash dump:");
3246+
# Dump the internal hash directly to see what it actually contains
3247+
if (ref($headers) eq 'CLIO::Compat::HTTP::Headers') {
3248+
my %h = %{$headers->{headers}} if ref($headers->{headers}) eq 'HASH';
3249+
while (my ($k, $v) = each %h) {
3250+
log_info('APIManager', " header[$k] = $v");
3251+
}
3252+
}
3253+
}
3254+
} else {
3255+
log_debug('APIManager', "Headers object: " . (defined($headers) ? (ref($headers) || $headers) : 'undef'));
3256+
}
3257+
3258+
# Also check what streaming_headers contains
3259+
if ($s->{streaming_headers}) {
3260+
log_debug('APIManager', "streaming_headers ref: " . ref($s->{streaming_headers}));
3261+
} else {
3262+
log_debug('APIManager', "streaming_headers is undef");
3263+
}
3264+
32363265
log_debug('APIManager', "handle_error_response returned, sending to error handler");
32373266
my $error_result = eval {
32383267
$self->{response_handler}->handle_error_response($resp, $s->{json}, 1,

lib/CLIO/Core/Config.pm

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -178,14 +178,27 @@ sub load {
178178
$config{api_base} = $stored_base;
179179
log_debug('Config', "Using stored api_base for provider '$config{provider}': $config{api_base}");
180180
} elsif ($config{provider} eq 'github_copilot') {
181-
# For GitHub Copilot, try to get user-specific API endpoint
182-
my $user_api_base = $self->_get_copilot_user_api_endpoint();
183-
if ($user_api_base) {
184-
$config{api_base} = $user_api_base;
185-
log_debug('Config', "Using user-specific GitHub Copilot API: $config{api_base}");
181+
# Check if a static PAT is configured for this provider
182+
# ghu_ tokens are fine-grained PATs that work with individual endpoint
183+
my $api_keys = $config{api_keys} || {};
184+
my $provider_key = $api_keys->{$config{provider}};
185+
my $direct_key = $config{api_key};
186+
my $static_pat = $provider_key || $direct_key;
187+
188+
if ($static_pat && $static_pat =~ /^ghu_/) {
189+
# Fine-grained PAT (ghu_) - use individual endpoint directly
190+
$config{api_base} = 'https://api.individual.githubcopilot.com';
191+
log_debug('Config', "Using individual endpoint for fine-grained PAT");
186192
} else {
187-
$config{api_base} = $provider_config->{api_base};
188-
log_debug('Config', "Using default GitHub Copilot API: $config{api_base}");
193+
# For GitHub Copilot, try to get user-specific API endpoint
194+
my $user_api_base = $self->_get_copilot_user_api_endpoint();
195+
if ($user_api_base) {
196+
$config{api_base} = $user_api_base;
197+
log_debug('Config', "Using user-specific GitHub Copilot API: $config{api_base}");
198+
} else {
199+
$config{api_base} = $provider_config->{api_base};
200+
log_debug('Config', "Using default GitHub Copilot API: $config{api_base}");
201+
}
189202
}
190203
} else {
191204
$config{api_base} = $provider_config->{api_base};

lib/CLIO/Core/PromptManager.pm

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ package CLIO::Core::PromptManager;
66
use strict;
77
use warnings;
88
use utf8;
9-
use CLIO::Core::Logger qw(log_error log_debug log_warning);
9+
use CLIO::Core::Logger qw(log_error log_debug log_warning log_info);
1010
use CLIO::Util::ConfigPath qw(get_config_file);
1111
use CLIO::Util::TextSanitizer qw(sanitize_text);
1212
use Carp qw(croak);

lib/CLIO/UI/Chat.pm

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1503,6 +1503,17 @@ sub _check_auth_migration {
15031503
require CLIO::Core::GitHubAuth;
15041504
my $auth = CLIO::Core::GitHubAuth->new(debug => 0);
15051505

1506+
# Check if a static API key is configured - if so, skip auth checks
1507+
# The api_key could be set via /api set key or stored in api_keys for provider
1508+
my $static_key = $self->{config}->get('api_key');
1509+
my $api_keys = $self->{config}->get('api_keys') || {};
1510+
$static_key ||= $api_keys->{$provider};
1511+
1512+
if ($static_key) {
1513+
log_info('Chat', "Static API key configured for github_copilot, skipping GitHub auth");
1514+
return;
1515+
}
1516+
15061517
# Check for migration needs first
15071518
my $reason = $auth->needs_reauth();
15081519
if ($reason) {
@@ -1642,7 +1653,9 @@ sub _prepopulate_session_data {
16421653
if ($model =~ m{^([a-z][a-z0-9_.-]*)/(.+)$}i && CLIO::Providers::provider_exists($1)) {
16431654
$model_provider = $1;
16441655
}
1645-
if ($provider eq 'github_copilot' && (!$model_provider || $model_provider eq 'github_copilot')) {
1656+
# Get billing multiplier only for GitHub Copilot provider
1657+
# Check both config provider AND model prefix (--model github_copilot/...)
1658+
if (($provider eq 'github_copilot' || $model_provider eq 'github_copilot') && (!$model_provider || $model_provider eq 'github_copilot')) {
16461659
eval {
16471660
require CLIO::Core::GitHubCopilotModelsAPI;
16481661
my $models_api = CLIO::Core::GitHubCopilotModelsAPI->new(debug => $self->{debug});

0 commit comments

Comments
 (0)